38 Commits

Author SHA1 Message Date
b814d07382 chore(owlry-rune): bump version to 0.4.5 2025-12-30 08:29:57 +01:00
0dead603ec chore(owlry-lua): bump version to 0.4.5 2025-12-30 08:29:57 +01:00
c1eb5ae2eb chore(plugins): bump all plugins to 0.4.5 2025-12-30 08:29:47 +01:00
07847c76d8 chore: bump version to 0.4.5 2025-12-30 08:29:25 +01:00
2dfce67f3b chore(owlry-rune): bump version to 0.4.4 2025-12-30 08:01:52 +01:00
b1198f4600 chore(owlry-lua): bump version to 0.4.4 2025-12-30 08:01:51 +01:00
e6776b803c chore(plugins): bump all plugins to 0.4.4 2025-12-30 08:01:15 +01:00
6e2d60466b chore: bump version to 0.4.4 2025-12-30 07:45:57 +01:00
8c1cf88474 feat: simplify ProviderType, add plugin priority, fix bookmarks SQLite
Core changes:
- Simplified ProviderType enum to 4 core types + Plugin(String)
- Added priority field to plugin API (API_VERSION = 3)
- Removed hardcoded plugin-specific code from core
- Updated filter.rs to use Plugin(type_id) for all plugins
- Updated main_window.rs UI mappings to derive from type_id
- Fixed weather/media SVG icon colors

Plugin changes:
- All plugins now declare their own priority values
- Widget plugins: weather(12000), pomodoro(11500), media(11000)
- Dynamic plugins: calc(10000), websearch(9000), filesearch(8000)
- Static plugins: priority 0 (frecency-based)

Bookmarks plugin:
- Replaced SQLx with rusqlite + bundled SQLite
- Fixes "undefined symbol: sqlite3_db_config" build errors
- No longer depends on system SQLite version

Config:
- Fixed config.example.toml invalid nested TOML sections
- Removed [providers.websearch], [providers.weather], etc.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 07:45:49 +01:00
ecaaae39e3 refactor(aur): rename meta packages to owlry-meta-*
Renamed for consistency:
- owlry-essentials → owlry-meta-essentials
- owlry-tools → owlry-meta-tools
- owlry-widgets → owlry-meta-widgets
- owlry-full → owlry-meta-full

New packages include replaces/conflicts for smooth transition.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 06:46:19 +01:00
96e9b09a31 docs(justfile): clarify meta-package static versioning
Meta-packages now use static 1.0.0 version, only bumping pkgrel
when dependencies change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 06:42:54 +01:00
e053f7d5d5 refactor(justfile): simplify AUR update for unified versioning
Removed _srcver handling since all packages now share the same version.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 06:29:56 +01:00
b1f11c076b chore: unify all package versions to 0.4.3
All crates (core, plugins, runtimes, plugin-api) now share the same
version number for simpler release management and clearer compatibility.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 06:27:23 +01:00
2d7fb33f30 fix(bookmarks): fix test calling non-existent method
Changed test to use static method process_chrome_folder_static.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 06:26:31 +01:00
3b1ff03ff8 chore: bump version to 0.4.2 2025-12-30 06:22:15 +01:00
e1fb63d6c4 fix(tests): make runtime tests environment-agnostic
Tests now verify functions don't panic rather than assuming
runtimes aren't installed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 06:22:04 +01:00
33e2f9cb5e chore(owlry-plugin-weather): bump version to 0.2.2 2025-12-30 06:13:44 +01:00
6b21602a07 chore(owlry-plugin-pomodoro): bump version to 0.2.2 2025-12-30 06:13:44 +01:00
4516865c21 chore(owlry-plugin-emoji): bump version to 0.2.2 2025-12-30 06:13:43 +01:00
4fbc7fc4c9 chore(owlry-plugin-bookmarks): bump version to 0.2.2 2025-12-30 06:13:43 +01:00
536c5c5012 chore: bump version to 0.4.1 2025-12-30 06:12:02 +01:00
abd4df6939 feat: add lazy loading, non-blocking bookmarks, and file search fix
- Add lazy loading for result lists (load more on scroll/selection)
- Add non-blocking bookmark loading with JSON cache
- Add Firefox favicon extraction and caching
- Fix dynamic provider filtering (files/calc/websearch in All mode)
- Fix clippy warnings across core and plugins
- Add apex-neon theme
- Add aur/ to gitignore

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 06:11:50 +01:00
43f7228be2 feat(justfile): add bump-meta and aur-publish-meta
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 03:54:13 +01:00
a1b47b8ba0 chore: bump all plugins and runtimes to 0.2.1 2025-12-30 03:49:36 +01:00
ccce9b8572 fix(justfile): handle _srcver for plugin AUR packages
Plugins use _srcver (core version) for source tarball, separate from
pkgver (plugin version). This allows independent plugin versioning
while still downloading from the core release tag.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 03:45:39 +01:00
ffb4c2f127 fix: prevent .SRCINFO creation in project root
- Use subshell for cd in aur-update-all recipe
- Add .SRCINFO to root .gitignore

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 03:40:08 +01:00
cde599db03 feat(justfile): add comprehensive release automation
- bump-all: bump plugins + runtimes together
- aur-update-all: update all 20 AUR packages
- aur-publish-all: publish all AUR packages
- release-all: complete release workflow (bump, tag, push, update AUR)
- release-core: core-only release workflow

Usage: just release-all 0.5.0 0.3.0
       (core version, plugin version)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 03:39:13 +01:00
cf8e33c976 fix(justfile): read version from crates/owlry/Cargo.toml
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 03:31:26 +01:00
85a18fc271 chore(owlry-rune): bump version to 0.2.0 2025-12-30 03:31:12 +01:00
67dad9c269 chore(owlry-lua): bump version to 0.2.0 2025-12-30 03:31:11 +01:00
3e8be3a4c5 chore(plugins): bump all plugins to 0.2.0 2025-12-30 03:30:56 +01:00
e83feb6ce4 chore: bump version to 0.4.0 2025-12-30 03:30:46 +01:00
bead9e4b4a feat(justfile): add per-crate version and AUR management
- Add show-versions, crate-version for version inspection
- Add bump-crate, bump-plugins for individual/batch version bumps
- Add aur-update-pkg, aur-publish-pkg for per-package AUR management
- Add aur-update-plugins, aur-publish-plugins for batch operations
- Add aur-status to show all AUR packages with versions

Supports independent versioning: core at 0.3.x, plugins at 0.1.0

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 03:28:35 +01:00
10722bc016 refactor: make Lua deps optional, remove unused dependencies
- Make meval, reqwest optional (behind 'lua' feature)
- Remove unused zbus and tokio dependencies
- Change default features from ["lua"] to []
- Update justfile install-local to use --no-default-features

Core binary now has 18 dependencies instead of 27 when built
without the lua feature, reducing compile time and binary size.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 03:14:53 +01:00
384dd016a0 feat: convert to workspace with native plugin architecture
BREAKING: Restructure from monolithic binary to modular plugin ecosystem

Architecture changes:
- Convert to Cargo workspace with crates/ directory
- Create owlry-plugin-api crate with ABI-stable interface (abi_stable)
- Move core binary to crates/owlry/
- Extract providers to native plugin crates (13 plugins)
- Add owlry-lua crate for Lua plugin runtime

Plugin system:
- Plugins loaded from /usr/lib/owlry/plugins/*.so
- Widget providers refresh automatically (universal, not hardcoded)
- Per-plugin config via [plugins.<name>] sections in config.toml
- Backwards compatible with [providers] config format

New features:
- just install-local: build and install core + all plugins
- Plugin config: weather and pomodoro read from [plugins.*]
- HostAPI for plugins: notifications, logging

Documentation:
- Update README with new package structure
- Add docs/PLUGINS.md with all plugin documentation
- Add docs/PLUGIN_DEVELOPMENT.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 03:01:37 +01:00
a582f0181c feat: bundle Weather Icons via GResources
Replace emoji icons with proper SVG icons loaded from GResources:

- Add Weather Icons (Erik Flowers) for weather conditions
- Add music note icon for media player widget
- Add tomato icon for pomodoro timer
- Create GResource manifest and build.rs for compilation
- Update providers to use resource paths for icons
- Image::from_resource() loads icons from compiled bundle

This ensures icons display consistently regardless of user's
installed icon theme. Weather icons are OFL licensed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 19:45:33 +01:00
97c6f655ca feat: add widget providers (weather, media, pomodoro)
- Weather widget with Open-Meteo/wttr.in/OpenWeatherMap API support
- 15-minute weather caching with geocoding for city names
- MPRIS media player widget with play/pause toggle via dbus-send
- Pomodoro timer widget with configurable work/break cycles
- Widgets display at top of results with emoji icons
- Improved terminal detection for Hyprland/Sway environments
- Updated gtk4 to 0.10, gtk4-layer-shell to 0.7

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 19:36:26 +01:00
8670909480 chore: add media.md to gitignore
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 18:08:47 +01:00
125 changed files with 22729 additions and 3229 deletions

13
.gitignore vendored
View File

@@ -1,2 +1,15 @@
/target
CLAUDE.md
media.md
# AUR packages (each is its own git repo for aur.archlinux.org)
aur/*/.git/
aur/*/pkg/
aur/*/src/
aur/*/*.tar.zst
aur/*/*.tar.gz
aur/*/*.tar.xz
aur/*/*.pkg.tar.*
# Keep PKGBUILD and .SRCINFO tracked
.SRCINFO
aur/

3253
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,78 +1,46 @@
[package]
name = "owlry"
version = "0.3.9"
[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"] }
[features]
default = []
# Enable verbose debug logging (for development/testing builds)
dev-logging = []
# Release profile (shared across all crates)
[profile.release]
lto = true
codegen-units = 1
panic = "abort"
strip = true
opt-level = "z" # Optimize for size
opt-level = "z"
[profile.dev]
opt-level = 0
debug = true
# For installing a testable build: cargo install --path . --profile dev-install --features dev-logging
# For installing a testable build: cargo install --path crates/owlry --profile dev-install --features dev-logging
[profile.dev-install]
inherits = "release"
strip = false
debug = 1 # Basic debug info for stack traces
debug = 1

226
README.md
View File

@@ -10,27 +10,57 @@ 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
- **Modular plugin architecture** — Install only what you need
- **Fuzzy search with tags** — Fast matching across names, descriptions, and category tags
- **Configurable tabs** — Customize header tabs and keyboard shortcuts
- **13 native plugins** — Calculator, clipboard, emoji, weather, media, and more
- **Widget providers** — Weather, media controls, and pomodoro timer at the top of results
- **Filter prefixes** — Scope searches with `:app`, `:cmd`, `:tag:development`, etc.
- **Calculator** — Quick math with `= 5+3` or `calc sin(pi/2)`
- **Web search** — Search the web with `? query`
- **File search** — Find files with `/ filename` (requires `fd` or `locate`)
- **Frecency ranking** — Frequently/recently used items rank higher
- **GTK4 theming** — System theme by default, with 9 built-in themes
- **Wayland native** — Uses Layer Shell for proper overlay behavior
- **Extensible** — Create custom plugins in Lua or Rune
## Installation
### Arch Linux (AUR)
```bash
# Minimal core (applications + commands only)
yay -S owlry
# or
paru -S owlry
# Add individual plugins
yay -S owlry-plugin-calculator owlry-plugin-weather
# Or install bundles:
yay -S owlry-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
```
### Available Packages
| 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:**
@@ -45,22 +75,25 @@ sudo apt install libgtk-4-dev libgtk4-layer-shell-dev
sudo dnf install gtk4-devel gtk4-layer-shell-devel
```
**Optional dependencies:**
```bash
# Clipboard history
sudo pacman -S cliphist wl-clipboard
# File search (choose one)
sudo pacman -S fd # recommended
sudo pacman -S mlocate # alternative
```
**Build (requires Rust 1.90+):**
```bash
git clone https://somegit.dev/Owlibou/owlry.git
cd owlry
cargo build --release
# Binary: target/release/owlry
# Build core only
cargo build --release -p owlry
# Build specific plugin
cargo build --release -p owlry-plugin-calculator
# Build everything
cargo build --release --workspace
```
**Install plugins manually:**
```bash
sudo mkdir -p /usr/lib/owlry/plugins
sudo cp target/release/libowlry_plugin_*.so /usr/lib/owlry/plugins/
```
## Usage
@@ -78,7 +111,7 @@ owlry --help # Show all options
|-----|--------|
| `Enter` | Launch selected item |
| `Escape` | Close launcher / exit submenu |
| `` / `` | Navigate results |
| `Up` / `Down` | Navigate results |
| `Tab` | Cycle filter tabs |
| `Shift+Tab` | Cycle filter tabs (reverse) |
| `Ctrl+1..9` | Toggle tab by position |
@@ -112,7 +145,7 @@ owlry --help # Show all options
| `/` | File search | `/ .bashrc` |
| `find ` | File search | `find config` |
## File Locations
## Configuration
Owlry follows the [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/latest/):
@@ -121,32 +154,17 @@ Owlry follows the [XDG Base Directory Specification](https://specifications.free
| `~/.config/owlry/config.toml` | Main configuration |
| `~/.config/owlry/themes/*.css` | Custom themes |
| `~/.config/owlry/style.css` | CSS overrides |
| `~/.config/owlry/plugins/` | User plugins (Lua/Rune) |
| `~/.local/share/owlry/scripts/` | User scripts |
| `~/.local/share/owlry/frecency.json` | Usage history |
## Configuration
Copy the example files:
```bash
# Config
mkdir -p ~/.config/owlry
cp /usr/share/doc/owlry/config.example.toml ~/.config/owlry/config.toml
# Optional: CSS overrides
cp /usr/share/doc/owlry/style.example.css ~/.config/owlry/style.css
# Optional: Example script
mkdir -p ~/.local/share/owlry/scripts
cp /usr/share/doc/owlry/scripts/example.sh ~/.local/share/owlry/scripts/
```
### Example Configuration
```toml
[general]
show_icons = true
max_results = 10
tabs = ["app", "cmd", "uuctl"] # Header tabs (Ctrl+1, Ctrl+2, etc.)
tabs = ["app", "cmd", "uuctl"]
# terminal_command = "kitty" # Auto-detected
# launch_wrapper = "uwsm app --" # Auto-detected
@@ -157,82 +175,43 @@ font_size = 14
border_radius = 12
# theme = "owl" # Or: catppuccin-mocha, nord, dracula, etc.
[providers]
applications = true
commands = true
uuctl = true
calculator = true
websearch = true
search_engine = "duckduckgo"
system = true
ssh = true
clipboard = true
bookmarks = true
emoji = true
scripts = true
files = true
frecency = true
frecency_weight = 0.3
[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
```
### Tab Configuration
## Plugin System
Customize which providers appear as header tabs:
Owlry uses a modular plugin architecture. Plugins are loaded from:
- `/usr/lib/owlry/plugins/*.so` — System plugins (AUR packages)
- `~/.config/owlry/plugins/` — User plugins (requires `owlry-lua` or `owlry-rune`)
### Disabling Plugins
Add plugin IDs to the disabled list in your config:
```toml
[general]
# Available: app, cmd, uuctl, bookmark, calc, clip, dmenu,
# emoji, file, script, ssh, sys, web
tabs = ["app", "cmd", "ssh", "sys"]
[plugins]
disabled = ["emoji", "pomodoro"]
```
Keyboard shortcuts `Ctrl+1` through `Ctrl+9` map to tab positions.
### Creating Custom Plugins
## Providers
| Provider | Description | Trigger |
|----------|-------------|---------|
| **Applications** | `.desktop` files from XDG directories | `:app` |
| **Commands** | Executables in `$PATH` | `:cmd` |
| **System** | Shutdown, reboot, suspend, lock, BIOS | `:sys` |
| **SSH** | Hosts from `~/.ssh/config` | `:ssh` |
| **Clipboard** | History via cliphist | `:clip` |
| **Bookmarks** | Chrome, Brave, Edge, Vivaldi | `:bm` |
| **Emoji** | 300+ searchable emoji | `:emoji` |
| **Scripts** | User scripts | `:script` |
| **Calculator** | Math expressions | `=` or `:calc` |
| **Web Search** | Configurable engine | `?` or `:web` |
| **Files** | fd/locate search | `/` or `:file` |
| **systemd** | User services with actions | `:uuctl` |
### Tags
Items are tagged for better search:
- **Applications**: Categories from `.desktop` files (development, utility, etc.)
- **System**: `power`, `system`
- **SSH**: `ssh`
- **Scripts**: `script`
- **systemd**: `systemd`, `service`
Filter by tag with `:tag:tagname`:
```
:tag:development # Show development apps
:tag:utility vim # Search utilities for "vim"
```
### Scripts
Create executable scripts in `~/.local/share/owlry/scripts/`:
```bash
mkdir -p ~/.local/share/owlry/scripts
cat > ~/.local/share/owlry/scripts/backup.sh << 'EOF'
#!/bin/bash
rsync -av ~/Documents /backup/
notify-send "Backup complete"
EOF
chmod +x ~/.local/share/owlry/scripts/backup.sh
```
See [docs/PLUGIN_DEVELOPMENT.md](docs/PLUGIN_DEVELOPMENT.md) for:
- Native plugin development (Rust)
- Lua plugin development
- Rune plugin development
- Available APIs
## Theming
@@ -271,23 +250,6 @@ Create `~/.config/owlry/themes/mytheme.css`:
}
```
### CSS Overrides
For tweaks without a full theme, create `~/.config/owlry/style.css`:
```css
/* Larger search input */
.owlry-search {
font-size: 18px;
padding: 12px 16px;
}
/* Hide tag badges */
.owlry-tag-badge {
display: none;
}
```
### CSS Variables
| Variable | Description |
@@ -299,8 +261,21 @@ For tweaks without a full theme, create `~/.config/owlry/style.css`:
| `--owlry-text-secondary` | Muted text |
| `--owlry-accent` | Accent color |
| `--owlry-accent-bright` | Bright accent |
| `--owlry-font-size` | Base font size |
| `--owlry-border-radius` | Corner radius |
## Architecture
```
owlry (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
@@ -310,4 +285,5 @@ GNU General Public License v3.0 — see [LICENSE](LICENSE).
- [GTK4](https://gtk.org/) — UI toolkit
- [gtk4-layer-shell](https://github.com/wmww/gtk4-layer-shell) — Wayland Layer Shell
- [abi_stable](https://crates.io/crates/abi_stable) — ABI-stable Rust plugins
- [fuzzy-matcher](https://crates.io/crates/fuzzy-matcher) — Fuzzy search

View File

@@ -0,0 +1,46 @@
[package]
name = "owlry-lua"
version = "0.4.5"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Lua runtime for owlry plugins - enables loading user-created Lua plugins"
keywords = ["owlry", "plugin", "lua", "runtime"]
categories = ["development-tools"]
[lib]
crate-type = ["cdylib"] # Compile as dynamic library (.so)
[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"

View 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)
}

View 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()));
}
}

View 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
View 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 &registrations {
let full_id = format!("{}:{}", id, reg.name);
self.provider_map.insert(full_id, id.clone());
}
}
self.plugins.insert(id, plugin);
}
}
fn get_providers(&self) -> Vec<LuaProviderInfo> {
let mut providers = Vec::new();
for (plugin_id, plugin) in &self.plugins {
if let Ok(registrations) = plugin.get_provider_registrations() {
for reg in registrations {
let full_id = format!("{}:{}", plugin_id, reg.name);
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>() };
}
}

View 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());
}
}

View 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"));
}
}

View 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");
}
}

View File

@@ -0,0 +1,17 @@
[package]
name = "owlry-plugin-api"
version = "0.4.5"
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"] }

View 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>();
}
}
}

View File

@@ -0,0 +1,31 @@
[package]
name = "owlry-plugin-bookmarks"
version = "0.4.5"
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"] }

View 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("'\\''"));
}
}

View File

@@ -0,0 +1,23 @@
[package]
name = "owlry-plugin-calculator"
version = "0.4.5"
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"

View 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());
}
}

View File

@@ -0,0 +1,20 @@
[package]
name = "owlry-plugin-clipboard"
version = "0.4.5"
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"

View 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();
}
}

View File

@@ -0,0 +1,20 @@
[package]
name = "owlry-plugin-emoji"
version = "0.4.5"
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"

View File

@@ -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,58 +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,
tags: Vec::new(), // TODO: Extract category from emoji data
});
// 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);
// Emoji character is in description, name is the human-readable name
assert!(provider
.items()
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()
.any(|i| i.description.as_ref().is_some_and(|d| d.contains("😀"))));
.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());
}
}

View File

@@ -0,0 +1,23 @@
[package]
name = "owlry-plugin-filesearch"
version = "0.4.5"
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"

View 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());
}
}

View File

@@ -0,0 +1,23 @@
[package]
name = "owlry-plugin-media"
version = "0.4.5"
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"

View 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;
}
}

View File

@@ -0,0 +1,30 @@
[package]
name = "owlry-plugin-pomodoro"
version = "0.4.5"
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"

View 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);
}
}

View File

@@ -0,0 +1,23 @@
[package]
name = "owlry-plugin-scripts"
version = "0.4.5"
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"

View 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"));
}
}

View File

@@ -0,0 +1,23 @@
[package]
name = "owlry-plugin-ssh"
version = "0.4.5"
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"

View 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"));
}
}

View File

@@ -0,0 +1,20 @@
[package]
name = "owlry-plugin-system"
version = "0.4.5"
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"

View 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()
);
}
}
}

View File

@@ -0,0 +1,20 @@
[package]
name = "owlry-plugin-systemd"
version = "0.4.5"
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"

View 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");
}
}

View File

@@ -0,0 +1,33 @@
[package]
name = "owlry-plugin-weather"
version = "0.4.5"
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"

View 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(&current.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={}&current=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());
}
}

View File

@@ -0,0 +1,20 @@
[package]
name = "owlry-plugin-websearch"
version = "0.4.5"
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"

View 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
}
}

View File

@@ -0,0 +1,44 @@
[package]
name = "owlry-rune"
version = "0.4.5"
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"

View 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());
}
}

View 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);
}
}

View 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());
}
}

View 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"));
}
}

View 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
View File

@@ -0,0 +1,87 @@
[package]
name = "owlry"
version = "0.4.5"
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
View 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
View 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
View 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()
}
}

View 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(())
}
}

View File

@@ -37,35 +37,45 @@ impl ProviderFilter {
} 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::Uuctl);
set.insert(ProviderType::Plugin("uuctl".to_string()));
}
if config_providers.system {
set.insert(ProviderType::System);
set.insert(ProviderType::Plugin("system".to_string()));
}
if config_providers.ssh {
set.insert(ProviderType::Ssh);
set.insert(ProviderType::Plugin("ssh".to_string()));
}
if config_providers.clipboard {
set.insert(ProviderType::Clipboard);
set.insert(ProviderType::Plugin("clipboard".to_string()));
}
if config_providers.bookmarks {
set.insert(ProviderType::Bookmarks);
set.insert(ProviderType::Plugin("bookmarks".to_string()));
}
if config_providers.emoji {
set.insert(ProviderType::Emoji);
set.insert(ProviderType::Plugin("emoji".to_string()));
}
if config_providers.scripts {
set.insert(ProviderType::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()));
}
// Note: Files, Calculator, WebSearch are dynamic providers
// that don't need to be in the filter set - they're triggered by prefix
// Default to apps if nothing enabled
if set.is_empty() {
set.insert(ProviderType::Application);
@@ -104,9 +114,11 @@ impl ProviderFilter {
#[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, self.enabled);
debug!("[Filter] Toggled ON {}, enabled: {:?}", provider_debug, self.enabled);
}
}
@@ -140,8 +152,8 @@ impl ProviderFilter {
/// Check if a provider should be searched
pub fn is_active(&self, provider: ProviderType) -> bool {
if let Some(prefix) = self.active_prefix {
provider == prefix
if let Some(ref prefix) = self.active_prefix {
&provider == prefix
} else {
self.enabled.contains(&provider)
}
@@ -155,10 +167,11 @@ impl ProviderFilter {
/// Get current active prefix if any
#[allow(dead_code)]
pub fn active_prefix(&self) -> Option<ProviderType> {
self.active_prefix
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();
@@ -186,37 +199,57 @@ impl ProviderFilter {
}
}
// Check for prefix patterns (with trailing space)
let prefixes = [
// Core provider prefixes
let core_prefixes: &[(&str, ProviderType)] = &[
(":app ", ProviderType::Application),
(":apps ", ProviderType::Application),
(":bm ", ProviderType::Bookmarks),
(":bookmark ", ProviderType::Bookmarks),
(":bookmarks ", ProviderType::Bookmarks),
(":calc ", ProviderType::Calculator),
(":calculator ", ProviderType::Calculator),
(":clip ", ProviderType::Clipboard),
(":clipboard ", ProviderType::Clipboard),
(":cmd ", ProviderType::Command),
(":command ", ProviderType::Command),
(":emoji ", ProviderType::Emoji),
(":emojis ", ProviderType::Emoji),
(":file ", ProviderType::Files),
(":files ", ProviderType::Files),
(":find ", ProviderType::Files),
(":script ", ProviderType::Scripts),
(":scripts ", ProviderType::Scripts),
(":ssh ", ProviderType::Ssh),
(":sys ", ProviderType::System),
(":system ", ProviderType::System),
(":power ", ProviderType::System),
(":uuctl ", ProviderType::Uuctl),
(":web ", ProviderType::WebSearch),
(":search ", ProviderType::WebSearch),
];
for (prefix_str, provider) in prefixes {
// 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 {
@@ -227,37 +260,54 @@ impl ProviderFilter {
}
}
// Handle prefix without trailing space (still typing)
let partial_prefixes = [
// Handle partial prefixes (still typing)
let partial_core: &[(&str, ProviderType)] = &[
(":app", ProviderType::Application),
(":apps", ProviderType::Application),
(":bm", ProviderType::Bookmarks),
(":bookmark", ProviderType::Bookmarks),
(":bookmarks", ProviderType::Bookmarks),
(":calc", ProviderType::Calculator),
(":calculator", ProviderType::Calculator),
(":clip", ProviderType::Clipboard),
(":clipboard", ProviderType::Clipboard),
(":cmd", ProviderType::Command),
(":command", ProviderType::Command),
(":emoji", ProviderType::Emoji),
(":emojis", ProviderType::Emoji),
(":file", ProviderType::Files),
(":files", ProviderType::Files),
(":find", ProviderType::Files),
(":script", ProviderType::Scripts),
(":scripts", ProviderType::Scripts),
(":ssh", ProviderType::Ssh),
(":sys", ProviderType::System),
(":system", ProviderType::System),
(":power", ProviderType::System),
(":uuctl", ProviderType::Uuctl),
(":web", ProviderType::WebSearch),
(":search", ProviderType::WebSearch),
];
for (prefix_str, provider) in partial_prefixes {
if trimmed == prefix_str {
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 {
@@ -282,61 +332,34 @@ impl ProviderFilter {
/// Get enabled providers for UI display (sorted)
pub fn enabled_providers(&self) -> Vec<ProviderType> {
let mut providers: Vec<_> = self.enabled.iter().copied().collect();
let mut providers: Vec<_> = self.enabled.iter().cloned().collect();
providers.sort_by_key(|p| match p {
ProviderType::Application => 0,
ProviderType::Bookmarks => 1,
ProviderType::Calculator => 2,
ProviderType::Clipboard => 3,
ProviderType::Command => 4,
ProviderType::Dmenu => 5,
ProviderType::Emoji => 6,
ProviderType::Files => 7,
ProviderType::Scripts => 8,
ProviderType::Ssh => 9,
ProviderType::System => 10,
ProviderType::Uuctl => 11,
ProviderType::WebSearch => 12,
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(prefix) = self.active_prefix {
if let Some(ref prefix) = self.active_prefix {
return match prefix {
ProviderType::Application => "Apps",
ProviderType::Bookmarks => "Bookmarks",
ProviderType::Calculator => "Calc",
ProviderType::Clipboard => "Clipboard",
ProviderType::Command => "Commands",
ProviderType::Dmenu => "dmenu",
ProviderType::Emoji => "Emoji",
ProviderType::Files => "Files",
ProviderType::Scripts => "Scripts",
ProviderType::Ssh => "SSH",
ProviderType::System => "System",
ProviderType::Uuctl => "uuctl",
ProviderType::WebSearch => "Web",
ProviderType::Plugin(_) => "Plugin",
};
}
let enabled: Vec<_> = self.enabled_providers();
if enabled.len() == 1 {
match enabled[0] {
match &enabled[0] {
ProviderType::Application => "Apps",
ProviderType::Bookmarks => "Bookmarks",
ProviderType::Calculator => "Calc",
ProviderType::Clipboard => "Clipboard",
ProviderType::Command => "Commands",
ProviderType::Dmenu => "dmenu",
ProviderType::Emoji => "Emoji",
ProviderType::Files => "Files",
ProviderType::Scripts => "Scripts",
ProviderType::Ssh => "SSH",
ProviderType::System => "System",
ProviderType::Uuctl => "uuctl",
ProviderType::WebSearch => "Web",
ProviderType::Plugin(_) => "Plugin",
}
} else {
"All"
@@ -369,6 +392,13 @@ mod tests {
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();

View File

@@ -3,27 +3,44 @@ 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;
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();
let args = CliArgs::parse_args();
#[cfg(feature = "dev-logging")]
{
debug!("┌─────────────────────────────────────────┐");

View 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);
}
}

View File

@@ -32,10 +32,6 @@ pub fn cache_home() -> Option<PathBuf> {
dirs::cache_dir()
}
/// Get user home directory
pub fn home() -> Option<PathBuf> {
dirs::home_dir()
}
// =============================================================================
// Owlry-specific directories
@@ -85,9 +81,12 @@ pub fn theme_file(name: &str) -> Option<PathBuf> {
// Data files
// =============================================================================
/// User scripts directory: `$XDG_DATA_HOME/owlry/scripts/`
pub fn scripts_dir() -> Option<PathBuf> {
owlry_data_dir().map(|p| p.join("scripts"))
/// User plugins directory: `$XDG_CONFIG_HOME/owlry/plugins/`
///
/// Plugins are stored in config because they contain user-installed code
/// that the user explicitly chose to add (similar to themes).
pub fn plugins_dir() -> Option<PathBuf> {
owlry_config_dir().map(|p| p.join("plugins"))
}
/// Frecency data file: `$XDG_DATA_HOME/owlry/frecency.json`
@@ -121,60 +120,16 @@ pub fn system_data_dirs() -> Vec<PathBuf> {
dirs
}
// =============================================================================
// External application paths
// =============================================================================
/// SSH config file: `~/.ssh/config`
pub fn ssh_config() -> Option<PathBuf> {
home().map(|p| p.join(".ssh").join("config"))
}
/// Firefox profile directory: `~/.mozilla/firefox/`
pub fn firefox_dir() -> Option<PathBuf> {
home().map(|p| p.join(".mozilla").join("firefox"))
}
/// Chromium-based browser bookmark paths (using XDG config where browsers support it)
pub fn chromium_bookmark_paths() -> Vec<PathBuf> {
let config = match config_home() {
Some(c) => c,
None => return Vec::new(),
};
vec![
// Google Chrome
config.join("google-chrome/Default/Bookmarks"),
// Chromium
config.join("chromium/Default/Bookmarks"),
// Brave
config.join("BraveSoftware/Brave-Browser/Default/Bookmarks"),
// Microsoft Edge
config.join("microsoft-edge/Default/Bookmarks"),
// Vivaldi
config.join("vivaldi/Default/Bookmarks"),
]
}
// =============================================================================
// Helper functions
// =============================================================================
/// Ensure a directory exists, creating it if necessary
pub fn ensure_dir(path: &PathBuf) -> std::io::Result<()> {
if !path.exists() {
std::fs::create_dir_all(path)?;
}
Ok(())
}
/// Ensure parent directory of a file exists
pub fn ensure_parent_dir(path: &PathBuf) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
if !parent.exists() {
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(())
}

View 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");
}
}

View 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));
}
}

View 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
}
}

View 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());
}
}

View 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");
}
}

View 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)
}

View 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('/'));
}
}

View 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());
}
}

View 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);
}
}

View 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]");
}
}

File diff suppressed because it is too large Load Diff

View 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>;

View 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());
}
}

View 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"));
}
}

View 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");
}
}

View 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()));
}
}

View 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"));
}
}

View 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");
}
}

View 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();
}
}

View 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");
}
}

View File

@@ -1,30 +1,22 @@
// Core providers (no plugin equivalents)
mod application;
mod bookmarks;
mod calculator;
mod clipboard;
mod command;
mod dmenu;
mod emoji;
mod files;
mod scripts;
mod ssh;
mod system;
mod uuctl;
mod websearch;
// 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 bookmarks::BookmarksProvider;
pub use calculator::CalculatorProvider;
pub use clipboard::ClipboardProvider;
pub use command::CommandProvider;
pub use dmenu::DmenuProvider;
pub use emoji::EmojiProvider;
pub use files::FileSearchProvider;
pub use scripts::ScriptsProvider;
pub use ssh::SshProvider;
pub use system::SystemProvider;
pub use uuctl::UuctlProvider;
pub use websearch::WebSearchProvider;
// Re-export native provider for plugin loading
pub use native_provider::NativeProvider;
use fuzzy_matcher::FuzzyMatcher;
use fuzzy_matcher::skim::SkimMatcherV2;
@@ -50,21 +42,20 @@ pub struct LaunchItem {
pub tags: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
/// 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,
Bookmarks,
Calculator,
Clipboard,
/// Built-in: Shell commands from PATH
Command,
/// Built-in: Pipe-based input (dmenu compatibility)
Dmenu,
Emoji,
Files,
Scripts,
Ssh,
System,
Uuctl,
WebSearch,
/// Plugin-defined provider type with its type_id (e.g., "calc", "weather", "emoji")
Plugin(String),
}
impl std::str::FromStr for ProviderType {
@@ -72,23 +63,12 @@ impl std::str::FromStr for ProviderType {
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),
"bookmark" | "bookmarks" | "bm" => Ok(ProviderType::Bookmarks),
"calc" | "calculator" => Ok(ProviderType::Calculator),
"clip" | "clipboard" => Ok(ProviderType::Clipboard),
"cmd" | "command" | "commands" => Ok(ProviderType::Command),
"dmenu" => Ok(ProviderType::Dmenu),
"emoji" | "emojis" => Ok(ProviderType::Emoji),
"file" | "files" | "find" => Ok(ProviderType::Files),
"script" | "scripts" => Ok(ProviderType::Scripts),
"ssh" => Ok(ProviderType::Ssh),
"sys" | "system" | "power" => Ok(ProviderType::System),
"uuctl" => Ok(ProviderType::Uuctl),
"web" | "websearch" | "search" => Ok(ProviderType::WebSearch),
_ => Err(format!(
"Unknown provider: '{}'. Valid: app, bookmark, calc, clip, cmd, emoji, file, script, ssh, sys, web",
s
)),
// Everything else is a plugin
other => Ok(ProviderType::Plugin(other.to_string())),
}
}
}
@@ -97,18 +77,9 @@ 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::Bookmarks => write!(f, "bookmark"),
ProviderType::Calculator => write!(f, "calc"),
ProviderType::Clipboard => write!(f, "clip"),
ProviderType::Command => write!(f, "cmd"),
ProviderType::Dmenu => write!(f, "dmenu"),
ProviderType::Emoji => write!(f, "emoji"),
ProviderType::Files => write!(f, "file"),
ProviderType::Scripts => write!(f, "script"),
ProviderType::Ssh => write!(f, "ssh"),
ProviderType::System => write!(f, "sys"),
ProviderType::Uuctl => write!(f, "uuctl"),
ProviderType::WebSearch => write!(f, "web"),
ProviderType::Plugin(type_id) => write!(f, "{}", type_id),
}
}
}
@@ -124,29 +95,31 @@ pub trait Provider: Send {
/// Manages all providers and handles searching
pub struct ProviderManager {
/// Static providers (apps, commands, and native static plugins)
providers: Vec<Box<dyn Provider>>,
calculator: CalculatorProvider,
websearch: WebSearchProvider,
filesearch: FileSearchProvider,
/// 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 {
#[allow(dead_code)]
pub fn new() -> Self {
Self::with_search_engine("duckduckgo")
}
pub fn with_search_engine(search_engine: &str) -> Self {
Self::with_config(search_engine, "kitty")
}
pub fn with_config(search_engine: &str, terminal: &str) -> Self {
/// 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(),
calculator: CalculatorProvider::new(),
websearch: WebSearchProvider::with_engine(search_engine),
filesearch: FileSearchProvider::new(),
dynamic_providers: Vec::new(),
widget_providers: Vec::new(),
matcher: SkimMatcherV2::default(),
};
@@ -159,18 +132,28 @@ impl ProviderManager {
dmenu.enable();
manager.providers.push(Box::new(dmenu));
} else {
// Normal mode: use all standard providers
// Core providers (no plugin equivalents)
manager.providers.push(Box::new(ApplicationProvider::new()));
manager.providers.push(Box::new(CommandProvider::new()));
manager.providers.push(Box::new(UuctlProvider::new()));
// New providers
manager.providers.push(Box::new(SystemProvider::new()));
manager.providers.push(Box::new(SshProvider::with_terminal(terminal)));
manager.providers.push(Box::new(ClipboardProvider::new()));
manager.providers.push(Box::new(BookmarksProvider::new()));
manager.providers.push(Box::new(EmojiProvider::new()));
manager.providers.push(Box::new(ScriptsProvider::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
@@ -187,6 +170,7 @@ impl ProviderManager {
}
pub fn refresh_all(&mut self) {
// Refresh static providers (fast, local operations)
for provider in &mut self.providers {
provider.refresh();
info!(
@@ -195,6 +179,71 @@ impl ProviderManager {
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)]
@@ -284,9 +333,9 @@ impl ProviderManager {
results
}
/// Search with frecency boosting, calculator support, and tag filtering
/// Search with frecency boosting, dynamic providers, and tag filtering
pub fn search_with_frecency(
&mut self,
&self,
query: &str,
max_results: usize,
filter: &crate::filter::ProviderFilter,
@@ -299,58 +348,43 @@ impl ProviderManager {
let mut results: Vec<(LaunchItem, i64)> = Vec::new();
// Check for calculator query (= or calc prefix)
if CalculatorProvider::is_calculator_query(query) {
if let Some(calc_result) = self.calculator.evaluate(query) {
#[cfg(feature = "dev-logging")]
debug!("[Search] Calculator result: {}", calc_result.name);
results.push((calc_result, 10000));
}
}
// Also check for raw expression when in :calc filter mode
else if filter.active_prefix() == Some(ProviderType::Calculator)
&& CalculatorProvider::looks_like_expression(query)
{
if let Some(calc_result) = self.calculator.evaluate_raw(query) {
results.push((calc_result, 10000));
// 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));
}
}
}
// Check for web search query
if WebSearchProvider::is_websearch_query(query) {
if let Some(web_result) = self.websearch.evaluate(query) {
// Web search results get a high score to appear first
results.push((web_result, 9000));
}
}
// Also check for raw query when in :web filter mode
else if filter.active_prefix() == Some(ProviderType::WebSearch) && !query.is_empty() {
if let Some(web_result) = self.websearch.evaluate_raw(query) {
results.push((web_result, 9000));
}
}
// Check for file search query
if FileSearchProvider::is_file_query(query) {
let file_results = self.filesearch.evaluate(query);
#[cfg(feature = "dev-logging")]
debug!("[Search] File search returned {} results", file_results.len());
for (idx, item) in file_results.into_iter().enumerate() {
// Score decreases for each result to maintain order
results.push((item, 8000 - idx as i64));
}
}
// Also check for raw query when in :file filter mode
else if filter.active_prefix() == Some(ProviderType::Files) && !query.is_empty() {
let file_results = self.filesearch.evaluate_raw(query);
for (idx, item) in file_results.into_iter().enumerate() {
results.push((item, 8000 - 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 mut items: Vec<(LaunchItem, i64)> = self
let items: Vec<(LaunchItem, i64)> = self
.providers
.iter()
.filter(|p| filter.is_active(p.provider_type()))
@@ -370,9 +404,11 @@ impl ProviderManager {
})
.collect();
items.sort_by(|a, b| b.1.cmp(&a.1));
items.truncate(max_results);
return items;
// 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
@@ -383,11 +419,10 @@ impl ProviderManager {
.flat_map(|provider| {
provider.items().iter().filter_map(|item| {
// Apply tag filter if present
if let Some(tag) = tag_filter {
if !item.tags.iter().any(|t| t.to_lowercase().contains(tag)) {
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
@@ -446,4 +481,84 @@ impl ProviderManager {
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
}
}

View 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");
}
}

View File

@@ -67,6 +67,18 @@
opacity: 1;
}
/* Symbolic icons - inherit text color */
.owlry-symbolic-icon {
-gtk-icon-style: symbolic;
}
/* Emoji icon - displayed as large text */
.owlry-emoji-icon {
font-size: 24px;
min-width: 32px;
min-height: 32px;
}
/* Result name */
.owlry-result-name {
font-size: var(--owlry-font-size, 14px);
@@ -81,7 +93,7 @@
/* Result description */
.owlry-result-description {
font-size: calc(var(--owlry-font-size, 14px) - 2px);
color: var(--owlry-text-secondary, alpha(@theme_fg_color, 0.7));
color: var(--owlry-text-secondary, alpha(@theme_fg_color, 0.85));
margin-top: 2px;
}
@@ -166,6 +178,22 @@
color: var(--owlry-badge-web, @teal_3);
}
/* Widget provider badges */
.owlry-badge-media {
background-color: alpha(var(--owlry-badge-media, #ec4899), 0.2);
color: var(--owlry-badge-media, #ec4899);
}
.owlry-badge-weather {
background-color: alpha(var(--owlry-badge-weather, #06b6d4), 0.2);
color: var(--owlry-badge-weather, #06b6d4);
}
.owlry-badge-pomo {
background-color: alpha(var(--owlry-badge-pomo, #f97316), 0.2);
color: var(--owlry-badge-pomo, #f97316);
}
/* Header bar */
.owlry-header {
margin-bottom: 4px;
@@ -283,6 +311,25 @@
border-color: alpha(var(--owlry-badge-web, @teal_3), 0.4);
}
/* Widget filter buttons */
.owlry-filter-media:checked {
background-color: alpha(var(--owlry-badge-media, #ec4899), 0.2);
color: var(--owlry-badge-media, #ec4899);
border-color: alpha(var(--owlry-badge-media, #ec4899), 0.4);
}
.owlry-filter-weather:checked {
background-color: alpha(var(--owlry-badge-weather, #06b6d4), 0.2);
color: var(--owlry-badge-weather, #06b6d4);
border-color: alpha(var(--owlry-badge-weather, #06b6d4), 0.4);
}
.owlry-filter-pomodoro:checked {
background-color: alpha(var(--owlry-badge-pomo, #f97316), 0.2);
color: var(--owlry-badge-pomo, #f97316);
border-color: alpha(var(--owlry-badge-pomo, #f97316), 0.4);
}
/* Hints bar at bottom */
.owlry-hints {
padding-top: 8px;
@@ -291,7 +338,7 @@
.owlry-hints-label {
font-size: calc(var(--owlry-font-size, 14px) - 4px);
color: var(--owlry-text-secondary, alpha(@theme_fg_color, 0.7));
color: var(--owlry-text-secondary, alpha(@theme_fg_color, 0.75));
letter-spacing: 0.5px;
}
@@ -321,13 +368,13 @@ scrollbar slider:active {
font-weight: 500;
padding: 1px 6px;
border-radius: 4px;
background-color: alpha(var(--owlry-border, @borders), 0.3);
color: var(--owlry-text-secondary, alpha(@theme_fg_color, 0.6));
background-color: alpha(var(--owlry-border, @borders), 0.5);
color: var(--owlry-text-secondary, alpha(@theme_fg_color, 0.9));
margin-top: 2px;
}
.owlry-result-row:selected .owlry-tag-badge {
background-color: alpha(var(--owlry-accent-bright, @theme_selected_fg_color), 0.2);
background-color: alpha(var(--owlry-accent-bright, @theme_selected_fg_color), 0.25);
color: var(--owlry-accent-bright, @theme_selected_fg_color);
}

View 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>

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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
}

View File

@@ -1,7 +1,8 @@
use crate::config::Config;
use crate::data::FrecencyStore;
use crate::filter::ProviderFilter;
use crate::providers::{LaunchItem, ProviderManager, ProviderType, UuctlProvider};
use crate::providers::{LaunchItem, ProviderManager, ProviderType};
use crate::ui::submenu;
use crate::ui::ResultRow;
use gtk4::gdk::Key;
use gtk4::prelude::*;
@@ -34,6 +35,19 @@ struct SubmenuState {
saved_search: String,
}
/// State for lazy loading results
#[derive(Default)]
struct LazyLoadState {
/// All matching results (may be more than displayed)
all_results: Vec<LaunchItem>,
/// Number of items currently displayed
displayed_count: usize,
}
/// Number of items to display initially and per batch
const INITIAL_RESULTS: usize = 15;
const LOAD_MORE_BATCH: usize = 10;
pub struct MainWindow {
window: ApplicationWindow,
search_entry: Entry,
@@ -50,6 +64,11 @@ pub struct MainWindow {
submenu_state: Rc<RefCell<SubmenuState>>,
/// Parsed tab config (ProviderTypes for cycling)
tab_order: Rc<Vec<ProviderType>>,
/// Custom prompt text (overrides dynamic placeholder when set)
#[allow(dead_code)]
custom_prompt: Option<String>,
/// Lazy loading state
lazy_state: Rc<RefCell<LazyLoadState>>,
}
impl MainWindow {
@@ -59,6 +78,7 @@ impl MainWindow {
providers: Rc<RefCell<ProviderManager>>,
frecency: Rc<RefCell<FrecencyStore>>,
filter: Rc<RefCell<ProviderFilter>>,
custom_prompt: Option<String>,
) -> Self {
let cfg = config.borrow();
@@ -110,24 +130,21 @@ impl MainWindow {
.build();
filter_tabs.add_css_class("owlry-filter-tabs");
// Parse tabs config to ProviderTypes
let tab_order: Vec<ProviderType> = cfg
.general
.tabs
.iter()
.filter_map(|s| s.parse().ok())
.collect();
let tab_order = Rc::new(tab_order);
// Get enabled providers from filter (which respects CLI --mode/--providers or config)
// This makes tabs dynamic based on what's actually enabled
let enabled = filter.borrow().enabled_providers();
let tab_strings: Vec<String> = enabled.iter().map(|p| p.to_string()).collect();
let tab_order = Rc::new(enabled);
// Create toggle buttons for each provider (from config)
let filter_buttons = Self::create_filter_buttons(&filter_tabs, &filter, &cfg.general.tabs);
// Create toggle buttons for each enabled provider
let filter_buttons = Self::create_filter_buttons(&filter_tabs, &filter, &tab_strings);
let filter_buttons = Rc::new(RefCell::new(filter_buttons));
header_box.append(&mode_label);
header_box.append(&filter_tabs);
// Search entry with dynamic placeholder
let placeholder = Self::build_placeholder(&filter.borrow());
// Search entry with dynamic placeholder (or custom prompt if provided)
let placeholder = custom_prompt.clone().unwrap_or_else(|| Self::build_placeholder(&filter.borrow()));
let search_entry = Entry::builder()
.placeholder_text(&placeholder)
.hexpand(true)
@@ -158,7 +175,7 @@ impl MainWindow {
hints_box.add_css_class("owlry-hints");
let hints_label = Label::builder()
.label(&Self::build_hints(&cfg.providers))
.label(Self::build_hints(&cfg.providers))
.halign(gtk4::Align::Center)
.hexpand(true)
.build();
@@ -174,6 +191,8 @@ impl MainWindow {
drop(cfg);
let lazy_state = Rc::new(RefCell::new(LazyLoadState::default()));
let main_window = Self {
window,
search_entry,
@@ -189,14 +208,62 @@ impl MainWindow {
filter_buttons,
submenu_state: Rc::new(RefCell::new(SubmenuState::default())),
tab_order,
custom_prompt,
lazy_state,
};
main_window.setup_signals();
main_window.setup_lazy_loading();
main_window.update_results("");
// Ensure search entry has focus when window is shown
main_window.search_entry.grab_focus();
// Schedule widget refresh after window is shown
// Widget providers (weather, media, pomodoro) may make network/dbus calls
// We defer this to avoid blocking startup, then re-render results
let providers_for_refresh = main_window.providers.clone();
let search_entry_for_refresh = main_window.search_entry.clone();
gtk4::glib::timeout_add_local_once(std::time::Duration::from_millis(50), move || {
providers_for_refresh.borrow_mut().refresh_widgets();
// Trigger UI update by emitting changed signal on search entry
search_entry_for_refresh.emit_by_name::<()>("changed", &[]);
});
// Set up periodic widget auto-refresh (every 5 seconds)
// Always refresh widgets (for pomodoro timer/notifications), but only update UI when visible
let providers_for_auto = main_window.providers.clone();
let current_results_for_auto = main_window.current_results.clone();
let submenu_state_for_auto = main_window.submenu_state.clone();
gtk4::glib::timeout_add_local(std::time::Duration::from_secs(5), move || {
// Skip UI updates if in submenu, but still refresh providers for notifications
let in_submenu = submenu_state_for_auto.borrow().active;
// Always refresh widget providers (pomodoro needs this for timer/notifications)
providers_for_auto.borrow_mut().refresh_widgets();
// Only update UI if not in submenu and widgets are visible
if !in_submenu {
// Collect widget type_ids first to avoid borrow conflicts
let widget_ids: Vec<String> = providers_for_auto
.borrow()
.widget_type_ids()
.map(|s| s.to_string())
.collect();
let mut results = current_results_for_auto.borrow_mut();
for type_id in &widget_ids {
if let Some(new_item) = providers_for_auto.borrow().get_widget_item(type_id)
&& let Some(existing) = results.iter_mut().find(|i| i.id == new_item.id)
{
existing.name = new_item.name;
existing.description = new_item.description;
}
}
}
gtk4::glib::ControlFlow::Continue
});
main_window
}
@@ -217,17 +284,27 @@ impl MainWindow {
}
};
let label = Self::provider_tab_label(provider_type);
let base_label = Self::provider_tab_label(&provider_type);
// Show number hint in the label for first 9 tabs (using superscript)
let label = if idx < 9 {
let superscript = match idx + 1 {
1 => "¹", 2 => "²", 3 => "³", 4 => "", 5 => "",
6 => "", 7 => "", 8 => "", 9 => "", _ => "",
};
format!("{}{}", base_label, superscript)
} else {
base_label.to_string()
};
let shortcut = format!("Ctrl+{}", idx + 1);
let button = ToggleButton::builder()
.label(label)
.label(&label)
.tooltip_text(&shortcut)
.active(filter.borrow().is_enabled(provider_type))
.active(filter.borrow().is_enabled(provider_type.clone()))
.build();
button.add_css_class("owlry-filter-button");
let css_class = Self::provider_css_class(provider_type);
let css_class = Self::provider_css_class(&provider_type);
button.add_css_class(css_class);
container.append(&button);
@@ -238,40 +315,54 @@ impl MainWindow {
}
/// Get display label for a provider tab
fn provider_tab_label(provider: ProviderType) -> &'static str {
/// Core types have fixed labels; plugins derive labels from type_id
fn provider_tab_label(provider: &ProviderType) -> &'static str {
match provider {
ProviderType::Application => "Apps",
ProviderType::Bookmarks => "Bookmarks",
ProviderType::Calculator => "Calc",
ProviderType::Clipboard => "Clip",
ProviderType::Command => "Cmds",
ProviderType::Dmenu => "Dmenu",
ProviderType::Emoji => "Emoji",
ProviderType::Files => "Files",
ProviderType::Scripts => "Scripts",
ProviderType::Ssh => "SSH",
ProviderType::System => "System",
ProviderType::Uuctl => "uuctl",
ProviderType::WebSearch => "Web",
ProviderType::Plugin(type_id) => match type_id.as_str() {
"bookmarks" => "Bookmarks",
"calc" => "Calc",
"clipboard" => "Clip",
"emoji" => "Emoji",
"filesearch" => "Files",
"media" => "Media",
"pomodoro" => "Pomo",
"scripts" => "Scripts",
"ssh" => "SSH",
"system" => "System",
"uuctl" => "uuctl",
"weather" => "Weather",
"websearch" => "Web",
_ => "Plugin",
},
}
}
/// Get CSS class for a provider
fn provider_css_class(provider: ProviderType) -> &'static str {
/// Core types have fixed CSS classes; plugins derive from type_id
fn provider_css_class(provider: &ProviderType) -> &'static str {
match provider {
ProviderType::Application => "owlry-filter-app",
ProviderType::Bookmarks => "owlry-filter-bookmark",
ProviderType::Calculator => "owlry-filter-calc",
ProviderType::Clipboard => "owlry-filter-clip",
ProviderType::Command => "owlry-filter-cmd",
ProviderType::Dmenu => "owlry-filter-dmenu",
ProviderType::Emoji => "owlry-filter-emoji",
ProviderType::Files => "owlry-filter-file",
ProviderType::Scripts => "owlry-filter-script",
ProviderType::Ssh => "owlry-filter-ssh",
ProviderType::System => "owlry-filter-sys",
ProviderType::Uuctl => "owlry-filter-uuctl",
ProviderType::WebSearch => "owlry-filter-web",
ProviderType::Plugin(type_id) => match type_id.as_str() {
"bookmarks" => "owlry-filter-bookmark",
"calc" => "owlry-filter-calc",
"clipboard" => "owlry-filter-clip",
"emoji" => "owlry-filter-emoji",
"filesearch" => "owlry-filter-file",
"media" => "owlry-filter-media",
"pomodoro" => "owlry-filter-pomodoro",
"scripts" => "owlry-filter-script",
"ssh" => "owlry-filter-ssh",
"system" => "owlry-filter-sys",
"uuctl" => "owlry-filter-uuctl",
"weather" => "owlry-filter-weather",
"websearch" => "owlry-filter-web",
_ => "owlry-filter-plugin",
},
}
}
@@ -281,18 +372,24 @@ impl MainWindow {
.iter()
.map(|p| match p {
ProviderType::Application => "applications",
ProviderType::Bookmarks => "bookmarks",
ProviderType::Calculator => "calculator",
ProviderType::Clipboard => "clipboard",
ProviderType::Command => "commands",
ProviderType::Dmenu => "options",
ProviderType::Emoji => "emoji",
ProviderType::Files => "files",
ProviderType::Scripts => "scripts",
ProviderType::Ssh => "SSH hosts",
ProviderType::System => "system",
ProviderType::Uuctl => "uuctl units",
ProviderType::WebSearch => "web",
ProviderType::Plugin(type_id) => match type_id.as_str() {
"bookmarks" => "bookmarks",
"calc" => "calculator",
"clipboard" => "clipboard",
"emoji" => "emoji",
"filesearch" => "files",
"media" => "media",
"pomodoro" => "pomodoro",
"scripts" => "scripts",
"ssh" => "SSH hosts",
"system" => "system",
"uuctl" => "uuctl units",
"weather" => "weather",
"websearch" => "web",
_ => "plugins",
},
})
.collect();
@@ -379,7 +476,8 @@ impl MainWindow {
}
}
/// Enter submenu mode for a service
/// Enter submenu mode for an item with actions
#[allow(clippy::too_many_arguments)]
fn enter_submenu(
submenu_state: &Rc<RefCell<SubmenuState>>,
results_list: &ListBox,
@@ -387,21 +485,18 @@ impl MainWindow {
mode_label: &Label,
hints_label: &Label,
search_entry: &Entry,
unit_name: &str,
display_name: &str,
is_active: bool,
display_name: String,
actions: Vec<LaunchItem>,
) {
#[cfg(feature = "dev-logging")]
debug!("[UI] Entering submenu for service: {} (active={})", unit_name, is_active);
let actions = UuctlProvider::actions_for_service(unit_name, display_name, is_active);
debug!("[UI] Entering submenu: {} ({} actions)", display_name, actions.len());
// Save current state
{
let mut state = submenu_state.borrow_mut();
state.active = true;
state.service_name = unit_name.to_string();
state.display_name = display_name.to_string();
state.service_name = String::new(); // No longer specific to services
state.display_name = display_name.clone();
state.items = actions.clone();
state.saved_search = search_entry.text().to_string();
}
@@ -469,6 +564,7 @@ impl MainWindow {
let mode_label = self.mode_label.clone();
let search_entry_for_change = self.search_entry.clone();
let submenu_state = self.submenu_state.clone();
let lazy_state = self.lazy_state.clone();
self.search_entry.connect_changed(move |entry| {
let raw_query = entry.text();
@@ -516,26 +612,32 @@ impl MainWindow {
{
let mut f = filter.borrow_mut();
f.set_prefix(parsed.prefix);
f.set_prefix(parsed.prefix.clone());
}
mode_label.set_label(filter.borrow().mode_display_name());
if parsed.prefix.is_some() {
let prefix_name = match parsed.prefix.unwrap() {
if let Some(ref prefix) = parsed.prefix {
let prefix_name = match prefix {
ProviderType::Application => "applications",
ProviderType::Bookmarks => "bookmarks",
ProviderType::Calculator => "calculator",
ProviderType::Clipboard => "clipboard",
ProviderType::Command => "commands",
ProviderType::Dmenu => "options",
ProviderType::Emoji => "emoji",
ProviderType::Files => "files",
ProviderType::Scripts => "scripts",
ProviderType::Ssh => "SSH hosts",
ProviderType::System => "system",
ProviderType::Uuctl => "uuctl units",
ProviderType::WebSearch => "web",
ProviderType::Plugin(type_id) => match type_id.as_str() {
"bookmarks" => "bookmarks",
"calc" => "calculator",
"clipboard" => "clipboard",
"emoji" => "emoji",
"filesearch" => "files",
"media" => "media",
"pomodoro" => "pomodoro",
"scripts" => "scripts",
"ssh" => "SSH hosts",
"system" => "system",
"uuctl" => "uuctl units",
"weather" => "weather",
"websearch" => "web",
_ => "plugins",
},
};
search_entry_for_change
.set_placeholder_text(Some(&format!("Search {}...", prefix_name)));
@@ -563,11 +665,21 @@ impl MainWindow {
.collect()
};
// Clear existing results
while let Some(child) = results_list.first_child() {
results_list.remove(&child);
}
for item in &results {
// Lazy loading: store all results but only display initial batch
let initial_count = INITIAL_RESULTS.min(results.len());
{
let mut lazy = lazy_state.borrow_mut();
lazy.all_results = results.clone();
lazy.displayed_count = initial_count;
}
// Display only initial batch
for item in results.iter().take(initial_count) {
let row = ResultRow::new(item);
results_list.append(&row);
}
@@ -576,7 +688,8 @@ impl MainWindow {
results_list.select_row(Some(&first_row));
}
*current_results.borrow_mut() = results;
// current_results holds only what's displayed (for selection/activation)
*current_results.borrow_mut() = results.into_iter().take(initial_count).collect();
});
// Entry activate signal (Enter key in search entry)
@@ -584,13 +697,14 @@ impl MainWindow {
let current_results_for_activate = self.current_results.clone();
let config_for_activate = self.config.clone();
let frecency_for_activate = self.frecency.clone();
let providers_for_activate = self.providers.clone();
let window_for_activate = self.window.clone();
let submenu_state_for_activate = self.submenu_state.clone();
let mode_label_for_activate = self.mode_label.clone();
let hints_label_for_activate = self.hints_label.clone();
let search_entry_for_activate = self.search_entry.clone();
self.search_entry.connect_activate(move |_| {
self.search_entry.connect_activate(move |entry| {
let selected = results_list_for_activate
.selected_row()
.or_else(|| results_list_for_activate.row_at_index(0));
@@ -599,11 +713,26 @@ impl MainWindow {
let index = row.index() as usize;
let results = current_results_for_activate.borrow();
if let Some(item) = results.get(index) {
// Check if this is a submenu item
if let Some((unit_name, display_name, is_active)) =
UuctlProvider::parse_submenu_data(item)
{
drop(results); // Release borrow before calling enter_submenu
// Check if this is a submenu item and query the plugin for actions
let submenu_result = if submenu::is_submenu_item(item) {
if let Some((plugin_id, data)) = submenu::parse_submenu_command(&item.command) {
// Clone values before dropping borrow
let plugin_id = plugin_id.to_string();
let data = data.to_string();
let display_name = item.name.clone();
drop(results); // Release borrow before querying
providers_for_activate
.borrow()
.query_submenu_actions(&plugin_id, &data, &display_name)
} else {
drop(results);
None
}
} else {
None
};
if let Some((display_name, actions)) = submenu_result {
Self::enter_submenu(
&submenu_state_for_activate,
&results_list_for_activate,
@@ -611,14 +740,27 @@ impl MainWindow {
&mode_label_for_activate,
&hints_label_for_activate,
&search_entry_for_activate,
&unit_name,
&display_name,
is_active,
display_name,
actions,
);
} else {
// Execute the command
Self::launch_item(item, &config_for_activate.borrow(), &frecency_for_activate);
window_for_activate.close();
// Not a submenu item - execute the command
let results = current_results_for_activate.borrow();
if let Some(item) = results.get(index).cloned() {
drop(results);
let should_close = Self::handle_item_action(
&item,
&config_for_activate.borrow(),
&frecency_for_activate,
&providers_for_activate,
);
if should_close {
window_for_activate.close();
} else {
// Trigger search refresh for updated widget state
entry.emit_by_name::<()>("changed", &[]);
}
}
}
}
}
@@ -629,15 +771,15 @@ impl MainWindow {
let filter = self.filter.clone();
let search_entry = self.search_entry.clone();
let mode_label = self.mode_label.clone();
let ptype = *provider_type;
let ptype = provider_type.clone();
button.connect_toggled(move |btn| {
{
let mut f = filter.borrow_mut();
if btn.is_active() {
f.enable(ptype);
f.enable(ptype.clone());
} else {
f.disable(ptype);
f.disable(ptype.clone());
}
}
mode_label.set_label(filter.borrow().mode_display_name());
@@ -720,12 +862,11 @@ impl MainWindow {
Key::Up => {
if let Some(selected) = results_list.selected_row() {
let prev_index = selected.index() - 1;
if prev_index >= 0 {
if let Some(prev_row) = results_list.row_at_index(prev_index) {
if prev_index >= 0
&& let Some(prev_row) = results_list.row_at_index(prev_index) {
results_list.select_row(Some(&prev_row));
Self::scroll_to_row(&scrolled, &results_list, &prev_row);
}
}
}
gtk4::glib::Propagation::Stop
}
@@ -759,6 +900,7 @@ impl MainWindow {
// Ctrl+1-9 toggle specific providers based on tab order (only when not in submenu)
Key::_1 | Key::_2 | Key::_3 | Key::_4 | Key::_5 |
Key::_6 | Key::_7 | Key::_8 | Key::_9 if ctrl => {
info!("[UI] Ctrl+number detected: {:?}", key);
if !submenu_state.borrow().active {
let idx = match key {
Key::_1 => 0,
@@ -772,14 +914,18 @@ impl MainWindow {
Key::_9 => 8,
_ => return gtk4::glib::Propagation::Proceed,
};
if let Some(&provider) = tab_order.get(idx) {
info!("[UI] Toggling tab at index {}", idx);
if let Some(provider) = tab_order.get(idx) {
info!("[UI] Found provider: {:?}", provider);
Self::toggle_provider_button(
provider,
provider.clone(),
&filter,
&filter_buttons,
&search_entry,
&mode_label,
);
} else {
info!("[UI] No provider at index {}, tab_order len={}", idx, tab_order.len());
}
}
gtk4::glib::Propagation::Stop
@@ -794,6 +940,7 @@ impl MainWindow {
let current_results = self.current_results.clone();
let config = self.config.clone();
let frecency = self.frecency.clone();
let providers = self.providers.clone();
let window = self.window.clone();
let submenu_state = self.submenu_state.clone();
let results_list_for_click = self.results_list.clone();
@@ -805,11 +952,26 @@ impl MainWindow {
let index = row.index() as usize;
let results = current_results.borrow();
if let Some(item) = results.get(index) {
// Check if this is a submenu item
if let Some((unit_name, display_name, is_active)) =
UuctlProvider::parse_submenu_data(item)
{
drop(results);
// Check if this is a submenu item and query the plugin for actions
let submenu_result = if submenu::is_submenu_item(item) {
if let Some((plugin_id, data)) = submenu::parse_submenu_command(&item.command) {
// Clone values before dropping borrow
let plugin_id = plugin_id.to_string();
let data = data.to_string();
let display_name = item.name.clone();
drop(results);
providers
.borrow()
.query_submenu_actions(&plugin_id, &data, &display_name)
} else {
drop(results);
None
}
} else {
None
};
if let Some((display_name, actions)) = submenu_result {
Self::enter_submenu(
&submenu_state,
&results_list_for_click,
@@ -817,13 +979,22 @@ impl MainWindow {
&mode_label,
&hints_label,
&search_entry,
&unit_name,
&display_name,
is_active,
display_name,
actions,
);
} else {
Self::launch_item(item, &config.borrow(), &frecency);
window.close();
// Not a submenu item - execute the command
let results = current_results.borrow();
if let Some(item) = results.get(index).cloned() {
drop(results);
let should_close = Self::handle_item_action(&item, &config.borrow(), &frecency, &providers);
if should_close {
window.close();
} else {
// Trigger search refresh for updated widget state
search_entry.emit_by_name::<()>("changed", &[]);
}
}
}
}
});
@@ -842,25 +1013,65 @@ impl MainWindow {
}
let current = filter.borrow().enabled_providers();
let all_enabled = current.len() == tab_order.len();
let next = if current.len() == 1 {
let idx = tab_order.iter().position(|p| p == &current[0]).unwrap_or(0);
if forward {
tab_order[(idx + 1) % tab_order.len()]
// Cycle: All -> Provider1 -> Provider2 -> ... -> ProviderN -> All
// In "All" mode (all providers enabled), we go to first provider (forward) or last (backward)
// In single-provider mode, we go to next provider or back to All at the boundary
if all_enabled {
// Currently showing all, go to first (forward) or last (backward) single provider
let next = if forward {
tab_order[0].clone()
} else {
tab_order[(idx + tab_order.len() - 1) % tab_order.len()]
tab_order[tab_order.len() - 1].clone()
};
{
let mut f = filter.borrow_mut();
f.set_single_mode(next.clone());
}
for (ptype, button) in buttons.borrow().iter() {
button.set_active(ptype == &next);
}
} else if current.len() == 1 {
let idx = tab_order.iter().position(|p| p == &current[0]).unwrap_or(0);
let at_boundary = if forward { idx == tab_order.len() - 1 } else { idx == 0 };
if at_boundary {
// At boundary, go back to "All" mode
{
let mut f = filter.borrow_mut();
for provider in tab_order {
f.enable(provider.clone());
}
}
for (_, button) in buttons.borrow().iter() {
button.set_active(true);
}
} else {
// Move to next/previous provider
let next = if forward {
tab_order[idx + 1].clone()
} else {
tab_order[idx - 1].clone()
};
{
let mut f = filter.borrow_mut();
f.set_single_mode(next.clone());
}
for (ptype, button) in buttons.borrow().iter() {
button.set_active(ptype == &next);
}
}
} else {
tab_order[0]
};
{
let mut f = filter.borrow_mut();
f.set_single_mode(next);
}
for (ptype, button) in buttons.borrow().iter() {
button.set_active(*ptype == next);
// Some but not all providers enabled - go to first provider
let next = tab_order[0].clone();
{
let mut f = filter.borrow_mut();
f.set_single_mode(next.clone());
}
for (ptype, button) in buttons.borrow().iter() {
button.set_active(ptype == &next);
}
}
mode_label.set_label(filter.borrow().mode_display_name());
@@ -877,7 +1088,7 @@ impl MainWindow {
) {
{
let mut f = filter.borrow_mut();
f.toggle(provider);
f.toggle(provider.clone());
}
if let Some(button) = buttons.borrow().get(&provider) {
@@ -896,6 +1107,7 @@ impl MainWindow {
let use_frecency = cfg.providers.frecency;
drop(cfg);
// Fetch all matching results (up to max_results)
let results: Vec<LaunchItem> = if use_frecency {
self.providers
.borrow_mut()
@@ -912,11 +1124,21 @@ impl MainWindow {
.collect()
};
// Clear existing results
while let Some(child) = self.results_list.first_child() {
self.results_list.remove(&child);
}
for item in &results {
// Store all results for lazy loading
let initial_count = INITIAL_RESULTS.min(results.len());
{
let mut lazy = self.lazy_state.borrow_mut();
lazy.all_results = results.clone();
lazy.displayed_count = initial_count;
}
// Display initial batch only
for item in results.iter().take(initial_count) {
let row = ResultRow::new(item);
self.results_list.append(&row);
}
@@ -925,7 +1147,94 @@ impl MainWindow {
self.results_list.select_row(Some(&first_row));
}
*self.current_results.borrow_mut() = results;
// current_results holds what's currently displayed
*self.current_results.borrow_mut() = results.into_iter().take(initial_count).collect();
}
/// Set up lazy loading scroll detection
fn setup_lazy_loading(&self) {
let vadj = self.scrolled.vadjustment();
let results_list = self.results_list.clone();
let lazy_state = self.lazy_state.clone();
let current_results = self.current_results.clone();
// Load more on scroll
vadj.connect_value_changed(move |adj| {
let value = adj.value();
let upper = adj.upper();
let page_size = adj.page_size();
// Load more when near bottom (within 50px)
let near_bottom = upper > page_size && (value + page_size >= upper - 50.0);
if near_bottom {
Self::load_more_items(&lazy_state, &results_list, &current_results);
}
});
// Also load more when selecting rows near the end (keyboard navigation)
let lazy_state2 = self.lazy_state.clone();
let results_list2 = self.results_list.clone();
let current_results2 = self.current_results.clone();
self.results_list.connect_row_selected(move |_, row| {
if let Some(row) = row {
let index = row.index();
let lazy = lazy_state2.borrow();
let displayed = lazy.displayed_count;
let all_count = lazy.all_results.len();
drop(lazy);
// Load more if within 3 items of the end
if displayed < all_count && (index as usize) >= displayed.saturating_sub(3) {
Self::load_more_items(&lazy_state2, &results_list2, &current_results2);
}
}
});
}
/// Load more items from lazy state
fn load_more_items(
lazy_state: &Rc<RefCell<LazyLoadState>>,
results_list: &ListBox,
current_results: &Rc<RefCell<Vec<LaunchItem>>>,
) {
let mut lazy = lazy_state.borrow_mut();
let all_count = lazy.all_results.len();
let displayed = lazy.displayed_count;
if displayed < all_count {
// Load next batch
let new_end = (displayed + LOAD_MORE_BATCH).min(all_count);
for item in lazy.all_results[displayed..new_end].iter() {
let row = ResultRow::new(item);
results_list.append(&row);
}
lazy.displayed_count = new_end;
// Update current_results
let mut current = current_results.borrow_mut();
current.extend(lazy.all_results[displayed..new_end].iter().cloned());
}
}
/// Handle item activation - returns true if window should close
fn handle_item_action(
item: &LaunchItem,
config: &Config,
frecency: &Rc<RefCell<FrecencyStore>>,
providers: &Rc<RefCell<ProviderManager>>,
) -> bool {
// Check for plugin internal commands (format: PLUGIN_ID:action)
// These are handled by the plugin itself, not launched as shell commands
if providers.borrow().execute_plugin_action(&item.command) {
// Plugin handled the action - don't close window
// User might want to see updated state (e.g., pomodoro timer)
return false;
}
// Regular item launch
Self::launch_item(item, config, frecency);
true
}
fn launch_item(item: &LaunchItem, config: &Config, frecency: &Rc<RefCell<FrecencyStore>>) {
@@ -942,14 +1251,28 @@ impl MainWindow {
debug!("[UI] Launch details: terminal={}, provider={:?}", item.terminal, item.provider);
let cmd = if item.terminal {
format!("{} -e {}", config.general.terminal_command, item.command)
let terminal = config.general.terminal_command.as_deref().unwrap_or("xterm");
format!("{} -e {}", terminal, item.command)
} else {
item.command.clone()
};
// Detect if this is a shell command vs an application launch
// Shell commands: playerctl, dbus-send, systemctl, journalctl, or anything with shell operators
let is_shell_command = cmd.starts_with("playerctl ")
|| cmd.starts_with("dbus-send ")
|| cmd.starts_with("systemctl ")
|| cmd.starts_with("journalctl ")
|| cmd.contains(" | ")
|| cmd.contains(" && ")
|| cmd.contains(" || ")
|| cmd.contains(" > ")
|| cmd.contains(" < ");
// Use launch wrapper if configured (uwsm, hyprctl, etc.)
// But skip wrapper for shell commands - they need sh -c
let result = match &config.general.launch_wrapper {
Some(wrapper) if !wrapper.is_empty() => {
Some(wrapper) if !wrapper.is_empty() && !is_shell_command => {
info!("Using launch wrapper: {}", wrapper);
// Split wrapper into command and args (e.g., "uwsm app --" -> ["uwsm", "app", "--"])
let mut wrapper_parts: Vec<&str> = wrapper.split_whitespace().collect();

View File

@@ -1,5 +1,6 @@
mod main_window;
mod result_row;
pub mod submenu;
pub use main_window::MainWindow;
pub use result_row::ResultRow;

View File

@@ -0,0 +1,165 @@
use crate::providers::LaunchItem;
use gtk4::prelude::*;
use gtk4::{Box as GtkBox, Image, Label, ListBoxRow, Orientation, Widget};
#[allow(dead_code)]
pub struct ResultRow {
row: ListBoxRow,
}
/// Check if a string looks like an emoji (starts with a non-ASCII character
/// and is very short - typically 1-4 chars for complex emojis with ZWJ)
fn is_emoji_icon(s: &str) -> bool {
if s.is_empty() {
return false;
}
// Emojis are non-ASCII and typically very short (1-8 chars for complex ZWJ sequences)
let first_char = s.chars().next().unwrap();
!first_char.is_ascii() && s.chars().count() <= 8
}
impl ResultRow {
#[allow(clippy::new_ret_no_self)]
pub fn new(item: &LaunchItem) -> ListBoxRow {
let row = ListBoxRow::builder()
.selectable(true)
.activatable(true)
.build();
row.add_css_class("owlry-result-row");
let hbox = GtkBox::builder()
.orientation(Orientation::Horizontal)
.spacing(12)
.margin_top(8)
.margin_bottom(8)
.margin_start(12)
.margin_end(12)
.build();
// Icon - handle GResource paths, file paths, icon names, emojis, and fallbacks
let icon_widget: Widget = if let Some(icon_path) = &item.icon {
if is_emoji_icon(icon_path) {
// Emoji character - display as text label
let emoji_label = Label::builder()
.label(icon_path)
.width_request(32)
.height_request(32)
.valign(gtk4::Align::Center)
.halign(gtk4::Align::Center)
.build();
emoji_label.add_css_class("owlry-result-icon");
emoji_label.add_css_class("owlry-emoji-icon");
emoji_label.upcast()
} else if icon_path.starts_with("/org/owlry/launcher/icons/") {
// GResource path - load from bundled resources
let img = Image::from_resource(icon_path);
img.set_pixel_size(32);
img.add_css_class("owlry-result-icon");
// SVG icons from resources should be treated as symbolic for color inheritance
if icon_path.ends_with(".svg") {
img.add_css_class("owlry-symbolic-icon");
}
img.upcast()
} else if icon_path.starts_with('/') {
// Absolute file path
let img = Image::from_file(icon_path);
img.set_pixel_size(32);
img.add_css_class("owlry-result-icon");
img.upcast()
} else {
// Icon theme name
let img = Image::from_icon_name(icon_path);
img.set_pixel_size(32);
img.add_css_class("owlry-result-icon");
// Add symbolic class for icons ending with "-symbolic"
if icon_path.ends_with("-symbolic") {
img.add_css_class("owlry-symbolic-icon");
}
img.upcast()
}
} else {
// Default icon based on provider type (only core types, plugins should provide icons)
let default_icon = match &item.provider {
crate::providers::ProviderType::Application => "application-x-executable-symbolic",
crate::providers::ProviderType::Command => "utilities-terminal-symbolic",
crate::providers::ProviderType::Dmenu => "view-list-symbolic",
// Plugins should provide their own icon; fallback to generic addon icon
crate::providers::ProviderType::Plugin(_) => "application-x-addon-symbolic",
};
let img = Image::from_icon_name(default_icon);
img.set_pixel_size(32);
img.add_css_class("owlry-result-icon");
img.add_css_class("owlry-symbolic-icon");
img.upcast()
};
// Text container
let text_box = GtkBox::builder()
.orientation(Orientation::Vertical)
.hexpand(true)
.valign(gtk4::Align::Center)
.build();
// Name label
let name_label = Label::builder()
.label(&item.name)
.halign(gtk4::Align::Start)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.build();
name_label.add_css_class("owlry-result-name");
// Description label
if let Some(desc) = &item.description {
let desc_label = Label::builder()
.label(desc)
.halign(gtk4::Align::Start)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.build();
desc_label.add_css_class("owlry-result-description");
text_box.append(&name_label);
text_box.append(&desc_label);
} else {
text_box.append(&name_label);
}
// Tag badges (show first 3 tags)
if !item.tags.is_empty() {
let tags_box = GtkBox::builder()
.orientation(Orientation::Horizontal)
.spacing(4)
.halign(gtk4::Align::Start)
.build();
for tag in item.tags.iter().take(3) {
let tag_label = Label::builder()
.label(tag)
.build();
tag_label.add_css_class("owlry-tag-badge");
tags_box.append(&tag_label);
}
text_box.append(&tags_box);
}
// Provider badge
let badge = Label::builder()
.label(item.provider.to_string())
.halign(gtk4::Align::End)
.valign(gtk4::Align::Center)
.build();
badge.add_css_class("owlry-result-badge");
badge.add_css_class(&format!("owlry-badge-{}", item.provider));
hbox.append(&icon_widget);
hbox.append(&text_box);
hbox.append(&badge);
row.set_child(Some(&hbox));
row
}
}

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