62 Commits

Author SHA1 Message Date
1aa92ee1e5 chore(owlry-rune): bump version to 0.4.9 2026-01-02 16:18:19 +01:00
9532b3cfde chore(owlry-lua): bump version to 0.4.9 2026-01-02 16:18:18 +01:00
551e5d74ae chore(plugins): bump all plugins to 0.4.9 2026-01-02 16:18:18 +01:00
60eaffb2ab chore: bump version to 0.4.9 2026-01-02 16:18:08 +01:00
6d8d4a9f89 fix(providers): improve app discovery and launch reliability
- Add Keywords field from desktop files to searchable tags
  (fixes apps like Nautilus not found when searching by legacy name)
- Respect XDG_DATA_DIRS with proper fallbacks for app directories
- Add Flatpak, Snap, and Nix application directory support
- Simplify desktop file launch to use gio directly (guaranteed by GTK4)
- Add desktop notifications for launch failures
- Check desktop file existence before launch attempt
2026-01-02 16:18:00 +01:00
3ef9398655 chore: bump all crates to 0.4.8 2026-01-01 23:30:45 +01:00
46bb4bfb38 chore: bump version to 0.4.8 2026-01-01 23:28:09 +01:00
c8aed5faf5 fix(dmenu): print selection to stdout instead of executing
dmenu mode was incorrectly trying to execute the selected item
as a command (via hyprctl/sh). Now it properly prints the
selection to stdout, enabling standard dmenu piping workflows
like: git branch | owlry -m dmenu | xargs git checkout
2026-01-01 23:28:03 +01:00
bf8a31af78 chore: bump all crates to 0.4.7 2026-01-01 22:29:00 +01:00
e23bdf5cee fix(providers): enable submenu support for static native plugins
Static native plugins (systemd, clipboard, etc.) were being boxed as
Box<dyn Provider>, which lost access to the query() method needed for
submenu support. The Provider trait only has refresh() and items().

Add static_native_providers field to keep static native plugins as
NativeProvider instances, preserving their query() method. Update all
search methods and query_submenu_actions() to include this new list.

Fixes systemd plugin submenu not showing actions when selecting a service.
2026-01-01 22:14:43 +01:00
25c4d40d36 docs: add comprehensive usage documentation
- Expand CLI --help with examples, dmenu mode, and search prefixes
- Add dmenu mode section to README with practical examples
- Add plugin management CLI reference to README
- Update argument descriptions with all valid modes listed
2026-01-01 21:45:52 +01:00
b36dd2a438 chore: update bump-all to include core in single commit 2025-12-30 20:32:28 +01:00
35a0f580c3 chore(owlry-rune): bump version to 0.4.6 2025-12-30 20:23:58 +01:00
7ed36c58c2 chore(owlry-lua): bump version to 0.4.6 2025-12-30 20:23:57 +01:00
7cccd3b512 chore(plugins): bump all plugins to 0.4.6 2025-12-30 20:23:48 +01:00
9f6d0c5935 chore: bump version to 0.4.6 2025-12-30 20:23:38 +01:00
026a232e0c docs: add ROADMAP.md with feature ideas
- High value/low effort: hot-reload, frecency pruning, :recent, clipboard images
- Medium effort: universal actions, plugin settings UI, result capture
- Bigger bets: window switcher, cross-device sync, natural language, plugin marketplace
- Technical debt: meval→evalexpr, API compat, per-plugin config

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 09:04:55 +01:00
1557119448 docs: comprehensive documentation update
README.md:
- Fix bundle package names (add meta- prefix)
- Add Firefox support to bookmarks plugin description
- Add system paths table (plugins, runtimes, example config)
- Add Quick Start section for copying example config
- Expand config example with providers section

docs/PLUGINS.md:
- Add Firefox support to bookmarks
- Fix bundle package names
- Remove outdated [plugins.weather] and [plugins.pomodoro] config examples

docs/PLUGIN_DEVELOPMENT.md:
- Fix Rust edition from 2024 to 2021
- Add position and priority fields to ProviderInfo
- Add ProviderPosition enum documentation

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 08:49:30 +01:00
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
cb12ffbeca chore: bump version to 0.3.9 2025-12-29 18:06:55 +01:00
892333dbca style: reduce vertical spacing on result rows
- Row padding: 6px (was 8px)
- Row margin: 1px (was 2px)
- Tag badge margin: 2px (was 4px)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 18:06:50 +01:00
6d3d69d103 chore: bump version to 0.3.8 2025-12-29 17:58:51 +01:00
bec8fc332b feat: increase default window size and reduce padding
- Default dimensions: 700x500 (was 600x400)
- Main container padding: 12px (was 16px)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 17:58:47 +01:00
a750ef8559 docs: add example files to README configuration section
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 17:47:21 +01:00
7cbebd324f chore: bump version to 0.3.7 2025-12-29 17:44:57 +01:00
127 changed files with 23574 additions and 3609 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.6"
[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

286
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-meta-essentials # calculator, system, ssh, scripts, bookmarks
yay -S owlry-meta-widgets # weather, media, pomodoro
yay -S owlry-meta-tools # clipboard, emoji, websearch, filesearch, systemd
yay -S owlry-meta-full # everything
# For custom Lua/Rune plugins
yay -S owlry-lua # Lua 5.4 runtime
yay -S owlry-rune # Rune runtime
```
### Available Packages
| Package | Description |
|---------|-------------|
| `owlry` | Core 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` | Firefox, Chrome, Brave, Edge bookmarks |
| `owlry-plugin-websearch` | Web search (`? query`) |
| `owlry-plugin-filesearch` | File search (`/ filename`) |
| `owlry-plugin-systemd` | User services with actions |
| `owlry-plugin-weather` | Weather widget |
| `owlry-plugin-media` | MPRIS media controls |
| `owlry-plugin-pomodoro` | Pomodoro timer widget |
### Build from Source
**Dependencies:**
@@ -45,40 +75,71 @@ 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
```bash
owlry # Launch with defaults
owlry --mode app # Applications only
owlry --providers app,cmd # Specific providers
owlry --help # Show all options
owlry # Launch with all providers
owlry -m app # Applications only
owlry -m cmd # PATH commands only
owlry -p app,cmd # Multiple specific providers
owlry -m calc # Calculator plugin only (if installed)
owlry --help # Show all options with examples
```
### dmenu Mode
Owlry is dmenu-compatible. Pipe input for interactive selection:
```bash
# Basic selection
echo -e "Option A\nOption B\nOption C" | owlry -m dmenu
# Select from files
ls ~/Documents | owlry -m dmenu
# Git branch checkout
git branch | owlry -m dmenu --prompt "checkout:" | xargs git checkout
# Kill a process
ps -eo comm | sort -u | owlry -m dmenu --prompt "kill:" | xargs pkill
# Select and open a project
find ~/projects -maxdepth 1 -type d | owlry -m dmenu | xargs code
# Package manager search
pacman -Ssq | owlry -m dmenu --prompt "install:" | xargs sudo pacman -S
```
The `--prompt` flag sets a custom label for the search input.
### Keyboard Shortcuts
| Key | Action |
|-----|--------|
| `Enter` | Launch selected item |
| `Escape` | Close launcher / exit submenu |
| `` / `` | Navigate results |
| `Up` / `Down` | Navigate results |
| `Tab` | Cycle filter tabs |
| `Shift+Tab` | Cycle filter tabs (reverse) |
| `Ctrl+1..9` | Toggle tab by position |
@@ -112,7 +173,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,13 +182,21 @@ Owlry follows the [XDG Base Directory Specification](https://specifications.free
| `~/.config/owlry/config.toml` | Main configuration |
| `~/.config/owlry/themes/*.css` | Custom themes |
| `~/.config/owlry/style.css` | CSS overrides |
| `~/.config/owlry/plugins/` | User plugins (Lua/Rune) |
| `~/.local/share/owlry/scripts/` | User scripts |
| `~/.local/share/owlry/frecency.json` | Usage history |
## Configuration
System locations:
| Path | Purpose |
|------|---------|
| `/usr/lib/owlry/plugins/*.so` | Installed native plugins |
| `/usr/lib/owlry/runtimes/*.so` | Lua/Rune script runtimes |
| `/usr/share/doc/owlry/config.example.toml` | Example configuration |
### Quick Start
Copy the example config:
```bash
# Copy example config
mkdir -p ~/.config/owlry
cp /usr/share/doc/owlry/config.example.toml ~/.config/owlry/config.toml
```
@@ -138,94 +207,88 @@ cp /usr/share/doc/owlry/config.example.toml ~/.config/owlry/config.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
[appearance]
width = 600
height = 400
width = 850
height = 650
font_size = 14
border_radius = 12
# theme = "owl" # Or: catppuccin-mocha, nord, dracula, etc.
[plugins]
disabled = [] # Plugin IDs to disable, e.g., ["emoji", "pomodoro"]
[providers]
applications = true
commands = true
uuctl = true
calculator = true
websearch = true
applications = true # .desktop files
commands = true # PATH executables
frecency = true # Boost frequently used items
frecency_weight = 0.3 # 0.0-1.0
# Web search engine: google, duckduckgo, bing, startpage, brave, ecosia
search_engine = "duckduckgo"
system = true
ssh = true
clipboard = true
bookmarks = true
emoji = true
scripts = true
files = true
frecency = true
frecency_weight = 0.3
```
### Tab Configuration
See `/usr/share/doc/owlry/config.example.toml` for all options with documentation.
Customize which providers appear as header tabs:
## Plugin System
Owlry uses a modular plugin architecture. Plugins are loaded from:
- `/usr/lib/owlry/plugins/*.so` — System plugins (AUR packages)
- `~/.config/owlry/plugins/` — User plugins (requires `owlry-lua` or `owlry-rune`)
### Disabling Plugins
Add plugin IDs to the disabled list in your config:
```toml
[general]
# Available: app, cmd, uuctl, bookmark, calc, clip, dmenu,
# emoji, file, script, ssh, sys, web
tabs = ["app", "cmd", "ssh", "sys"]
[plugins]
disabled = ["emoji", "pomodoro"]
```
Keyboard shortcuts `Ctrl+1` through `Ctrl+9` map to tab positions.
## Providers
| Provider | Description | Trigger |
|----------|-------------|---------|
| **Applications** | `.desktop` files from XDG directories | `:app` |
| **Commands** | Executables in `$PATH` | `:cmd` |
| **System** | Shutdown, reboot, suspend, lock, BIOS | `:sys` |
| **SSH** | Hosts from `~/.ssh/config` | `:ssh` |
| **Clipboard** | History via cliphist | `:clip` |
| **Bookmarks** | Chrome, Brave, Edge, Vivaldi | `:bm` |
| **Emoji** | 300+ searchable emoji | `:emoji` |
| **Scripts** | User scripts | `:script` |
| **Calculator** | Math expressions | `=` or `:calc` |
| **Web Search** | Configurable engine | `?` or `:web` |
| **Files** | fd/locate search | `/` or `:file` |
| **systemd** | User services with actions | `:uuctl` |
### Tags
Items are tagged for better search:
- **Applications**: Categories from `.desktop` files (development, utility, etc.)
- **System**: `power`, `system`
- **SSH**: `ssh`
- **Scripts**: `script`
- **systemd**: `systemd`, `service`
Filter by tag with `:tag:tagname`:
```
:tag:development # Show development apps
:tag:utility vim # Search utilities for "vim"
```
### Scripts
Create executable scripts in `~/.local/share/owlry/scripts/`:
### Plugin Management CLI
```bash
mkdir -p ~/.local/share/owlry/scripts
cat > ~/.local/share/owlry/scripts/backup.sh << 'EOF'
#!/bin/bash
rsync -av ~/Documents /backup/
notify-send "Backup complete"
EOF
chmod +x ~/.local/share/owlry/scripts/backup.sh
# List installed plugins
owlry plugin list
owlry plugin list --enabled # Only enabled
owlry plugin list --available # Show registry plugins
# Search registry
owlry plugin search "weather"
# Install/remove
owlry plugin install <name> # From registry
owlry plugin install ./my-plugin # From local path
owlry plugin remove <name>
# Enable/disable
owlry plugin enable <name>
owlry plugin disable <name>
# Plugin info
owlry plugin info <name>
owlry plugin commands <name> # List plugin CLI commands
# Create new plugin
owlry plugin create my-plugin # Lua (default)
owlry plugin create my-plugin -r rune # Rune
# Run plugin command
owlry plugin run <plugin-id> <command> [args...]
```
### Creating Custom Plugins
See [docs/PLUGIN_DEVELOPMENT.md](docs/PLUGIN_DEVELOPMENT.md) for:
- Native plugin development (Rust)
- Lua plugin development
- Rune plugin development
- Available APIs
## Theming
### Built-in Themes
@@ -263,23 +326,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 |
@@ -291,8 +337,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
@@ -302,4 +361,5 @@ GNU General Public License v3.0 — see [LICENSE](LICENSE).
- [GTK4](https://gtk.org/) — UI toolkit
- [gtk4-layer-shell](https://github.com/wmww/gtk4-layer-shell) — Wayland Layer Shell
- [abi_stable](https://crates.io/crates/abi_stable) — ABI-stable Rust plugins
- [fuzzy-matcher](https://crates.io/crates/fuzzy-matcher) — Fuzzy search

109
ROADMAP.md Normal file
View File

@@ -0,0 +1,109 @@
# Owlry Roadmap
Feature ideas and future development plans for Owlry.
## High Value, Low Effort
### Plugin hot-reload
Detect `.so` file changes in `/usr/lib/owlry/plugins/` and reload without restarting the launcher. The loader infrastructure already exists.
### Frecency pruning
Add `max_entries` and `max_age_days` config options. Prune old entries on startup to prevent `frecency.json` from growing unbounded.
### `:recent` prefix
Show last N launched items. Data already exists in frecency.json — just needs a provider to surface it.
### Clipboard images
`cliphist` supports images. Extend the clipboard plugin to show image thumbnails in results.
---
## Medium Effort, High Value
### Actions on any result
Generalize the submenu system beyond systemd. Every result type gets contextual actions:
| Provider | Actions |
|----------|---------|
| Applications | Open, Open in terminal, Show .desktop location |
| Files | Open, Open folder, Copy path, Delete |
| SSH | Connect, Copy hostname, Edit config |
| Bookmarks | Open, Copy URL, Open incognito |
| Clipboard | Paste, Delete from history |
This is the difference between a launcher and a command palette.
### Plugin settings UI
A `:settings` provider that lists installed plugins and their configurable options. Edit values inline, writes to `config.toml`.
### Result action capture
Calculator shows `= 5+3 → 8`. Allow pressing Tab or Ctrl+C to copy the result to clipboard instead of "launching" it. Useful for calculator, file paths, URLs.
---
## Bigger Bets
### Window switcher with live thumbnails
A `windows` plugin using Wayland screencopy to show live thumbnails of open windows. Hyprland and Sway expose window lists via IPC. Could replace Alt+Tab.
### Cross-device bookmark sync
Firefox and Chrome sync bookmarks across devices. Parse sync metadata to show "recently added on other devices" or "bookmarks from phone".
### Natural language commands
Parse simple natural language into system commands:
```
"shutdown in 30 minutes" → systemd-run --user --on-active=30m systemctl poweroff
"remind me in 1 hour" → notify-send scheduled via at/systemd timer
"volume 50%" → wpctl set-volume @DEFAULT_AUDIO_SINK@ 0.5
```
Local pattern matching, no AI/cloud required.
### Plugin marketplace
A curated registry of third-party Lua/Rune plugins with one-command install:
```bash
owlry plugin install github-notifications
owlry plugin install todoist
owlry plugin install spotify-controls
```
The script runtimes make this viable without recompiling.
---
## Technical Debt
### Split monorepo for user build efficiency
Currently, a small core fix requires all 16 AUR packages to rebuild (same source tarball). Split into 3 repos:
| Repo | Contents | Versioning |
|------|----------|------------|
| `owlry` | Core binary | Independent |
| `owlry-plugin-api` | ABI interface (crates.io) | Semver, conservative |
| `owlry-plugins` | 13 plugins + 2 runtimes | Independent per plugin |
**Execution order:**
1. Publish `owlry-plugin-api` to crates.io
2. Update monorepo to use crates.io dependency
3. Create `owlry-plugins` repo, move plugins + runtimes
4. Slim current repo to core-only
5. Update AUR PKGBUILDs with new source URLs
**Benefit:** Core bugfix = 1 rebuild. Plugin fix = 1 rebuild. Third-party plugins possible via crates.io.
### Replace meval with evalexpr
`meval` depends on `nom v1.2.4` which will be rejected by future Rust versions. Migrate calculator plugin and Lua runtime to `evalexpr` v13+.
### Plugin API backwards compatibility
When `API_VERSION` increments, provide a compatibility shim so v3 plugins work with v4 core. Prevents ecosystem fragmentation.
### Per-plugin configuration
Current flat `[providers]` config doesn't scale. Design a `[plugins.weather]`, `[plugins.pomodoro]` structure that plugins can declare and the core validates.
---
## Priority
If we had to pick one: **Actions on any result**. It transforms every provider from "search and launch" to "search and do anything". The ROI is massive.

View File

@@ -0,0 +1,46 @@
[package]
name = "owlry-lua"
version = "0.4.9"
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.9"
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.9"
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.9"
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.9"
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.9"
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.9"
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.9"
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.9"
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.9"
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.9"
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.9"
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.9"
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.9"
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.9"
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.9"
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.9"
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");
}
}

254
crates/owlry/src/cli.rs Normal file
View File

@@ -0,0 +1,254 @@
//! 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",
long_about = "An owl-themed application launcher for Wayland, built with GTK4 and Layer Shell.\n\n\
Owlry provides fuzzy search across applications, commands, and plugins.\n\
Native plugins add features like calculator, clipboard, emoji, weather, and more.",
version,
after_help = "\
EXAMPLES:
owlry Launch with all providers
owlry -m app Applications only
owlry -m cmd PATH commands only
owlry -m dmenu dmenu-compatible mode (reads from stdin)
owlry -p app,cmd Multiple providers
owlry -m calc Calculator plugin only (if installed)
DMENU MODE:
Pipe input to owlry for interactive selection:
echo -e \"Option A\\nOption B\" | owlry -m dmenu
ls | owlry -m dmenu
git branch | owlry -m dmenu --prompt \"checkout:\"
SEARCH PREFIXES:
:app firefox Search applications
:cmd git Search PATH commands
= 5+3 Calculator (requires plugin)
? rust docs Web search (requires plugin)
/ .bashrc File search (requires plugin)
For configuration, see ~/.config/owlry/config.toml
For plugin management, see: owlry plugin --help"
)]
pub struct CliArgs {
/// Start in single-provider mode
///
/// Core modes: app, cmd, dmenu
/// Plugin modes: calc, clip, emoji, ssh, sys, bm, file, web, uuctl, weather, media, pomodoro
#[arg(long, short = 'm', value_parser = parse_provider, value_name = "MODE")]
pub mode: Option<ProviderType>,
/// Comma-separated list of enabled providers
///
/// Examples: -p app,cmd or -p app,calc,emoji
#[arg(long, short = 'p', value_delimiter = ',', value_parser = parse_provider, value_name = "PROVIDERS")]
pub providers: Option<Vec<ProviderType>>,
/// Custom prompt text for the search input
///
/// Useful in dmenu mode to indicate what the user is selecting.
/// Example: --prompt "Select file:"
#[arg(long, value_name = "TEXT")]
pub prompt: Option<String>,
/// Subcommand to run (if any)
#[command(subcommand)]
pub command: Option<Command>,
}
#[derive(Subcommand, Debug, Clone)]
pub enum Command {
/// Manage plugins
#[command(subcommand)]
Plugin(PluginCommand),
}
/// Plugin runtime type
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum PluginRuntime {
/// Lua runtime (requires owlry-lua package)
Lua,
/// Rune runtime (requires owlry-rune package)
Rune,
}
impl std::fmt::Display for PluginRuntime {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PluginRuntime::Lua => write!(f, "lua"),
PluginRuntime::Rune => write!(f, "rune"),
}
}
}
#[derive(Subcommand, Debug, Clone)]
pub enum PluginCommand {
/// List installed plugins
List {
/// Show only enabled plugins
#[arg(long)]
enabled: bool,
/// Show only disabled plugins
#[arg(long)]
disabled: bool,
/// Filter by runtime type (lua or rune)
#[arg(long, short = 'r', value_enum)]
runtime: Option<PluginRuntime>,
/// Show available plugins from registry instead of installed
#[arg(long)]
available: bool,
/// Force refresh of registry cache
#[arg(long)]
refresh: bool,
/// Output in JSON format
#[arg(long)]
json: bool,
},
/// Search for plugins in the registry
Search {
/// Search query (matches name, description, tags)
query: String,
/// Force refresh of registry cache
#[arg(long)]
refresh: bool,
/// Output in JSON format
#[arg(long)]
json: bool,
},
/// Show detailed information about a plugin
Info {
/// Plugin ID
name: String,
/// Show info from registry instead of installed plugin
#[arg(long)]
registry: bool,
/// Output in JSON format
#[arg(long)]
json: bool,
},
/// Install a plugin from registry, path, or URL
Install {
/// Plugin source (registry name, local path, or git URL)
source: String,
/// Force reinstall even if already installed
#[arg(long, short = 'f')]
force: bool,
},
/// Remove an installed plugin
Remove {
/// Plugin ID to remove
name: String,
/// Don't ask for confirmation
#[arg(long, short = 'y')]
yes: bool,
},
/// Update installed plugins
Update {
/// Specific plugin to update (all if not specified)
name: Option<String>,
},
/// Enable a disabled plugin
Enable {
/// Plugin ID to enable
name: String,
},
/// Disable an installed plugin
Disable {
/// Plugin ID to disable
name: String,
},
/// Create a new plugin from template
Create {
/// Plugin ID (directory name)
name: String,
/// Runtime type to use (default: lua)
#[arg(long, short = 'r', value_enum, default_value = "lua")]
runtime: PluginRuntime,
/// Target directory (default: current directory)
#[arg(long, short = 'd')]
dir: Option<String>,
/// Plugin display name
#[arg(long)]
display_name: Option<String>,
/// Plugin description
#[arg(long)]
description: Option<String>,
},
/// Validate a plugin's structure and manifest
Validate {
/// Path to plugin directory (default: current directory)
path: Option<String>,
},
/// Show available script runtimes
Runtimes,
/// Run a plugin command
///
/// Plugins can provide CLI commands that are invoked via:
/// owlry plugin run <plugin-id> <command> [args...]
///
/// Example:
/// owlry plugin run bookmark add https://example.com "My Bookmark"
Run {
/// Plugin ID
plugin_id: String,
/// Command to run
command: String,
/// Arguments to pass to the command
#[arg(trailing_var_arg = true)]
args: Vec<String>,
},
/// List commands provided by a plugin
Commands {
/// Plugin ID (optional - lists all if not specified)
plugin_id: Option<String>,
},
}
fn parse_provider(s: &str) -> Result<ProviderType, String> {
s.parse()
}
impl CliArgs {
pub fn parse_args() -> Self {
Self::parse()
}
}

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`
@@ -100,81 +99,71 @@ pub fn frecency_file() -> Option<PathBuf> {
// =============================================================================
/// System data directories for applications (XDG_DATA_DIRS)
///
/// Follows the XDG Base Directory Specification:
/// - $XDG_DATA_HOME/applications (defaults to ~/.local/share/applications)
/// - $XDG_DATA_DIRS/*/applications (defaults to /usr/local/share:/usr/share)
/// - Additional Flatpak and Snap directories
pub fn system_data_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
let mut seen = std::collections::HashSet::new();
// User data directory first
if let Some(data) = data_home() {
dirs.push(data.join("applications"));
}
// System directories
dirs.push(PathBuf::from("/usr/share/applications"));
dirs.push(PathBuf::from("/usr/local/share/applications"));
// Flatpak directories
if let Some(data) = data_home() {
dirs.push(data.join("flatpak/exports/share/applications"));
}
dirs.push(PathBuf::from("/var/lib/flatpak/exports/share/applications"));
dirs
}
// =============================================================================
// 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(),
// Helper to add unique directories
let mut add_dir = |path: PathBuf| {
if seen.insert(path.clone()) {
dirs.push(path);
}
};
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"),
]
// 1. User data directory first (highest priority)
if let Some(data) = data_home() {
add_dir(data.join("applications"));
}
// 2. XDG_DATA_DIRS - parse the environment variable
// Default per spec: /usr/local/share:/usr/share
let xdg_data_dirs = std::env::var("XDG_DATA_DIRS")
.unwrap_or_else(|_| "/usr/local/share:/usr/share".to_string());
for dir in xdg_data_dirs.split(':') {
if !dir.is_empty() {
add_dir(PathBuf::from(dir).join("applications"));
}
}
// 3. Always include standard system directories as fallback
// Some environments set XDG_DATA_DIRS without including these
add_dir(PathBuf::from("/usr/share/applications"));
add_dir(PathBuf::from("/usr/local/share/applications"));
// 4. Flatpak directories (user and system)
if let Some(data) = data_home() {
add_dir(data.join("flatpak/exports/share/applications"));
}
add_dir(PathBuf::from("/var/lib/flatpak/exports/share/applications"));
// 5. Snap directories
add_dir(PathBuf::from("/var/lib/snapd/desktop/applications"));
// 6. Nix directories (common on NixOS)
if let Some(home) = dirs::home_dir() {
add_dir(home.join(".nix-profile/share/applications"));
}
add_dir(PathBuf::from("/run/current-system/sw/share/applications"));
dirs
}
// =============================================================================
// 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

@@ -98,6 +98,15 @@ impl Provider for ApplicationProvider {
// Empty locale list for default locale
let locales: &[&str] = &[];
// Get current desktop environment(s) for OnlyShowIn/NotShowIn filtering
// XDG_CURRENT_DESKTOP can be colon-separated (e.g., "ubuntu:GNOME")
let current_desktops: Vec<String> = std::env::var("XDG_CURRENT_DESKTOP")
.unwrap_or_default()
.split(':')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
for path in Iter::new(dirs.into_iter()) {
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
@@ -125,6 +134,24 @@ impl Provider for ApplicationProvider {
continue;
}
// Apply OnlyShowIn/NotShowIn filters only if we know the current desktop
// If XDG_CURRENT_DESKTOP is not set, show all apps (don't filter)
if !current_desktops.is_empty() {
// OnlyShowIn: if set, current desktop must be in the list
if desktop_entry.only_show_in().is_some_and(|only| {
!current_desktops.iter().any(|de| only.contains(&de.as_str()))
}) {
continue;
}
// NotShowIn: if current desktop is in the list, skip
if desktop_entry.not_show_in().is_some_and(|not| {
current_desktops.iter().any(|de| not.contains(&de.as_str()))
}) {
continue;
}
}
let name = match desktop_entry.name(locales) {
Some(n) => n.to_string(),
None => continue,
@@ -135,12 +162,17 @@ impl Provider for ApplicationProvider {
None => continue,
};
// Extract categories as tags (lowercase for consistency)
let tags: Vec<String> = desktop_entry
// Extract categories and keywords as tags (lowercase for consistency)
let mut tags: Vec<String> = desktop_entry
.categories()
.map(|cats| cats.into_iter().map(|s| s.to_lowercase()).collect())
.unwrap_or_default();
// Add keywords for searchability (e.g., Nautilus has Name=Files but Keywords contains "nautilus")
if let Some(keywords) = desktop_entry.keywords(locales) {
tags.extend(keywords.into_iter().map(|s| s.to_lowercase()));
}
let item = LaunchItem {
id: path.to_string_lossy().to_string(),
name,
@@ -157,6 +189,13 @@ impl Provider for ApplicationProvider {
debug!("Found {} applications", self.items.len());
#[cfg(feature = "dev-logging")]
debug!(
"XDG_CURRENT_DESKTOP={:?}, scanned dirs count={}",
current_desktops,
Self::get_application_dirs().len()
);
// Sort alphabetically by name
self.items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
}
@@ -210,4 +249,18 @@ mod tests {
"bash -c 'echo %u'"
);
}
#[test]
fn test_clean_desktop_exec_preserves_env() {
// env VAR=value pattern should be preserved
assert_eq!(
clean_desktop_exec_field("env GDK_BACKEND=x11 UBUNTU_MENUPROXY=0 audacity %F"),
"env GDK_BACKEND=x11 UBUNTU_MENUPROXY=0 audacity"
);
// Multiple env vars
assert_eq!(
clean_desktop_exec_field("env FOO=bar BAZ=qux myapp %u"),
"env FOO=bar BAZ=qux myapp"
);
}
}

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

@@ -0,0 +1,616 @@
// Core providers (no plugin equivalents)
mod application;
mod command;
mod dmenu;
// Native plugin bridge
pub mod native_provider;
// Lua plugin bridge (optional)
#[cfg(feature = "lua")]
pub mod lua_provider;
// Re-exports for core providers
pub use application::ApplicationProvider;
pub use command::CommandProvider;
pub use dmenu::DmenuProvider;
// Re-export native provider for plugin loading
pub use native_provider::NativeProvider;
use fuzzy_matcher::FuzzyMatcher;
use fuzzy_matcher::skim::SkimMatcherV2;
use log::info;
#[cfg(feature = "dev-logging")]
use log::debug;
use crate::data::FrecencyStore;
/// Represents a single searchable/launchable item
#[derive(Debug, Clone)]
pub struct LaunchItem {
#[allow(dead_code)]
pub id: String,
pub name: String,
pub description: Option<String>,
pub icon: Option<String>,
pub provider: ProviderType,
pub command: String,
pub terminal: bool,
/// Tags/categories for filtering (e.g., from .desktop Categories)
pub tags: Vec<String>,
}
/// Provider type identifier for filtering and badge display
///
/// Core types are built-in providers. All native plugins use Plugin(type_id).
/// This keeps the core app free of plugin-specific knowledge.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ProviderType {
/// Built-in: Desktop applications from XDG directories
Application,
/// Built-in: Shell commands from PATH
Command,
/// Built-in: Pipe-based input (dmenu compatibility)
Dmenu,
/// Plugin-defined provider type with its type_id (e.g., "calc", "weather", "emoji")
Plugin(String),
}
impl std::str::FromStr for ProviderType {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
// Core built-in providers
"app" | "apps" | "application" | "applications" => Ok(ProviderType::Application),
"cmd" | "command" | "commands" => Ok(ProviderType::Command),
"dmenu" => Ok(ProviderType::Dmenu),
// Everything else is a plugin
other => Ok(ProviderType::Plugin(other.to_string())),
}
}
}
impl std::fmt::Display for ProviderType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ProviderType::Application => write!(f, "app"),
ProviderType::Command => write!(f, "cmd"),
ProviderType::Dmenu => write!(f, "dmenu"),
ProviderType::Plugin(type_id) => write!(f, "{}", type_id),
}
}
}
/// Trait for all search providers
pub trait Provider: Send {
#[allow(dead_code)]
fn name(&self) -> &str;
fn provider_type(&self) -> ProviderType;
fn refresh(&mut self);
fn items(&self) -> &[LaunchItem];
}
/// Manages all providers and handles searching
pub struct ProviderManager {
/// Core static providers (apps, commands, dmenu)
providers: Vec<Box<dyn Provider>>,
/// Static native plugin providers (need query() for submenu support)
static_native_providers: Vec<NativeProvider>,
/// Dynamic providers from native plugins (calculator, websearch, filesearch)
/// These are queried per-keystroke, not cached
dynamic_providers: Vec<NativeProvider>,
/// Widget providers from native plugins (weather, media, pomodoro)
/// These appear at the top of results
widget_providers: Vec<NativeProvider>,
/// Fuzzy matcher for search
matcher: SkimMatcherV2,
}
impl ProviderManager {
/// Create a new ProviderManager with 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(),
static_native_providers: Vec::new(),
dynamic_providers: Vec::new(),
widget_providers: Vec::new(),
matcher: SkimMatcherV2::default(),
};
// Check if running in dmenu mode (stdin has data)
let dmenu_mode = DmenuProvider::has_stdin_data();
if dmenu_mode {
// In dmenu mode, only use dmenu provider
let mut dmenu = DmenuProvider::new();
dmenu.enable();
manager.providers.push(Box::new(dmenu));
} else {
// Core providers (no plugin equivalents)
manager.providers.push(Box::new(ApplicationProvider::new()));
manager.providers.push(Box::new(CommandProvider::new()));
// Categorize native plugins based on their declared ProviderKind and ProviderPosition
for provider in native_providers {
let type_id = provider.type_id();
if provider.is_dynamic() {
// Dynamic providers declare ProviderKind::Dynamic
info!("Registered dynamic provider: {} ({})", provider.name(), type_id);
manager.dynamic_providers.push(provider);
} else if provider.is_widget() {
// Widgets declare ProviderPosition::Widget
info!("Registered widget provider: {} ({})", provider.name(), type_id);
manager.widget_providers.push(provider);
} else {
// Static native providers (keep as NativeProvider for query/submenu support)
info!("Registered static provider: {} ({})", provider.name(), type_id);
manager.static_native_providers.push(provider);
}
}
}
// Initial refresh
manager.refresh_all();
manager
}
#[allow(dead_code)]
pub fn is_dmenu_mode(&self) -> bool {
self.providers
.iter()
.any(|p| p.provider_type() == ProviderType::Dmenu)
}
pub fn refresh_all(&mut self) {
// Refresh core providers (apps, commands)
for provider in &mut self.providers {
provider.refresh();
info!(
"Provider '{}' loaded {} items",
provider.name(),
provider.items().len()
);
}
// Refresh static native providers (clipboard, emoji, ssh, etc.)
for provider in &mut self.static_native_providers {
provider.refresh();
info!(
"Static provider '{}' loaded {} items",
provider.name(),
provider.items().len()
);
}
// Widget providers are refreshed separately to avoid blocking startup
// Call refresh_widgets() after window is shown
// Dynamic providers don't need refresh (they query on demand)
}
/// Refresh widget providers (weather, media, pomodoro)
/// Call this separately from refresh_all() to avoid blocking startup
/// since widgets may make network requests or spawn processes
pub fn refresh_widgets(&mut self) {
for provider in &mut self.widget_providers {
provider.refresh();
info!(
"Widget '{}' loaded {} items",
provider.name(),
provider.items().len()
);
}
}
/// Find a native provider by type ID
/// Searches in all native provider lists (static, dynamic, widget)
pub fn find_native_provider(&self, type_id: &str) -> Option<&NativeProvider> {
// Check static native providers first (clipboard, emoji, ssh, systemd, etc.)
if let Some(p) = self.static_native_providers.iter().find(|p| p.type_id() == type_id) {
return Some(p);
}
// Check widget providers (pomodoro, weather, media)
if let Some(p) = self.widget_providers.iter().find(|p| p.type_id() == type_id) {
return Some(p);
}
// Then dynamic providers (calc, websearch, filesearch)
self.dynamic_providers.iter().find(|p| p.type_id() == type_id)
}
/// Execute a plugin action command
/// Command format: PLUGIN_ID:action_data (e.g., "POMODORO:start", "SYSTEMD:unit:restart")
/// Returns true if the command was handled by a plugin
pub fn execute_plugin_action(&self, command: &str) -> bool {
// Parse command format: PLUGIN_ID:action_data
if let Some(colon_pos) = command.find(':') {
let plugin_id = &command[..colon_pos];
let action = command; // Pass full command to plugin
// Find provider by type ID (case-insensitive for convenience)
let type_id = plugin_id.to_lowercase();
if let Some(provider) = self.find_native_provider(&type_id) {
provider.execute_action(action);
return true;
}
}
false
}
/// Add a dynamic provider (e.g., from a Lua plugin)
#[allow(dead_code)]
pub fn add_provider(&mut self, provider: Box<dyn Provider>) {
info!("Added plugin provider: {}", provider.name());
self.providers.push(provider);
}
/// Add multiple providers at once (for batch plugin loading)
#[allow(dead_code)]
pub fn add_providers(&mut self, providers: Vec<Box<dyn Provider>>) {
for provider in providers {
self.add_provider(provider);
}
}
/// Iterate over all static provider items (core + native static plugins)
fn all_static_items(&self) -> impl Iterator<Item = &LaunchItem> {
self.providers
.iter()
.flat_map(|p| p.items().iter())
.chain(self.static_native_providers.iter().flat_map(|p| p.items().iter()))
}
#[allow(dead_code)]
pub fn search(&self, query: &str, max_results: usize) -> Vec<(LaunchItem, i64)> {
if query.is_empty() {
// Return recent/popular items when query is empty
return self.all_static_items()
.take(max_results)
.map(|item| (item.clone(), 0))
.collect();
}
let mut results: Vec<(LaunchItem, i64)> = self.all_static_items()
.filter_map(|item| {
// Match against name and description
let name_score = self.matcher.fuzzy_match(&item.name, query);
let desc_score = item.description
.as_ref()
.and_then(|d| self.matcher.fuzzy_match(d, query));
let score = match (name_score, desc_score) {
(Some(n), Some(d)) => Some(n.max(d)),
(Some(n), None) => Some(n),
(None, Some(d)) => Some(d / 2), // Lower weight for description matches
(None, None) => None,
};
score.map(|s| (item.clone(), s))
})
.collect();
// Sort by score (descending)
results.sort_by(|a, b| b.1.cmp(&a.1));
results.truncate(max_results);
results
}
/// Search with provider filtering
pub fn search_filtered(
&self,
query: &str,
max_results: usize,
filter: &crate::filter::ProviderFilter,
) -> Vec<(LaunchItem, i64)> {
// Collect items from core providers
let core_items = self
.providers
.iter()
.filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter().cloned());
// Collect items from static native providers
let native_items = self
.static_native_providers
.iter()
.filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter().cloned());
if query.is_empty() {
return core_items
.chain(native_items)
.take(max_results)
.map(|item| (item, 0))
.collect();
}
let mut results: Vec<(LaunchItem, i64)> = core_items
.chain(native_items)
.filter_map(|item| {
let name_score = self.matcher.fuzzy_match(&item.name, query);
let desc_score = item
.description
.as_ref()
.and_then(|d| self.matcher.fuzzy_match(d, query));
let score = match (name_score, desc_score) {
(Some(n), Some(d)) => Some(n.max(d)),
(Some(n), None) => Some(n),
(None, Some(d)) => Some(d / 2),
(None, None) => None,
};
score.map(|s| (item, s))
})
.collect();
results.sort_by(|a, b| b.1.cmp(&a.1));
results.truncate(max_results);
results
}
/// Search with frecency boosting, dynamic providers, and tag filtering
pub fn search_with_frecency(
&self,
query: &str,
max_results: usize,
filter: &crate::filter::ProviderFilter,
frecency: &FrecencyStore,
frecency_weight: f64,
tag_filter: Option<&str>,
) -> Vec<(LaunchItem, i64)> {
#[cfg(feature = "dev-logging")]
debug!("[Search] query={:?}, max={}, frecency_weight={}", query, max_results, frecency_weight);
let mut results: Vec<(LaunchItem, i64)> = Vec::new();
// Add widget items first (highest priority) - only when:
// 1. No specific filter prefix is active
// 2. Query is empty (user hasn't started searching)
// This keeps widgets visible on launch but hides them during active search
// Widgets are always visible regardless of filter settings (they declare position via API)
if filter.active_prefix().is_none() && query.is_empty() {
// Widget priority comes from plugin-declared priority field
for provider in &self.widget_providers {
let base_score = provider.priority() as i64;
for (idx, item) in provider.items().iter().enumerate() {
results.push((item.clone(), base_score - idx as i64));
}
}
}
// Query dynamic providers (calculator, websearch, filesearch)
// Only query if:
// 1. Their specific filter is active (e.g., :file prefix or Files tab selected), OR
// 2. No specific single-mode filter is active (showing all providers)
if !query.is_empty() {
for provider in &self.dynamic_providers {
// Skip if this provider type is explicitly filtered out
if !filter.is_active(provider.provider_type()) {
continue;
}
let dynamic_results = provider.query(query);
// Priority comes from plugin-declared priority field
let base_score = provider.priority() as i64;
for (idx, item) in dynamic_results.into_iter().enumerate() {
results.push((item, base_score - idx as i64));
}
}
}
// Empty query (after checking special providers) - return frecency-sorted items
if query.is_empty() {
// Collect items from core providers
let core_items = self
.providers
.iter()
.filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter().cloned());
// Collect items from static native providers
let native_items = self
.static_native_providers
.iter()
.filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter().cloned());
let items: Vec<(LaunchItem, i64)> = core_items
.chain(native_items)
.filter(|item| {
// Apply tag filter if present
if let Some(tag) = tag_filter {
item.tags.iter().any(|t| t.to_lowercase().contains(tag))
} else {
true
}
})
.map(|item| {
let frecency_score = frecency.get_score(&item.id);
let boosted = (frecency_score * frecency_weight * 100.0) as i64;
(item, boosted)
})
.collect();
// Combine widgets (already in results) with frecency items
results.extend(items);
results.sort_by(|a, b| b.1.cmp(&a.1));
results.truncate(max_results);
return results;
}
// Regular search with frecency boost and tag matching
// Helper closure for scoring items
let score_item = |item: &LaunchItem| -> Option<(LaunchItem, i64)> {
// Apply tag filter if present
if let Some(tag) = tag_filter
&& !item.tags.iter().any(|t| t.to_lowercase().contains(tag))
{
return None;
}
let name_score = self.matcher.fuzzy_match(&item.name, query);
let desc_score = item
.description
.as_ref()
.and_then(|d| self.matcher.fuzzy_match(d, query));
// Also match against tags (lower weight)
let tag_score = item
.tags
.iter()
.filter_map(|t| self.matcher.fuzzy_match(t, query))
.max()
.map(|s| s / 3); // Lower weight for tag matches
let base_score = match (name_score, desc_score, tag_score) {
(Some(n), Some(d), Some(t)) => Some(n.max(d).max(t)),
(Some(n), Some(d), None) => Some(n.max(d)),
(Some(n), None, Some(t)) => Some(n.max(t)),
(Some(n), None, None) => Some(n),
(None, Some(d), Some(t)) => Some((d / 2).max(t)),
(None, Some(d), None) => Some(d / 2),
(None, None, Some(t)) => Some(t),
(None, None, None) => None,
};
base_score.map(|s| {
let frecency_score = frecency.get_score(&item.id);
let frecency_boost = (frecency_score * frecency_weight * 10.0) as i64;
(item.clone(), s + frecency_boost)
})
};
// Search core providers
for provider in &self.providers {
if !filter.is_active(provider.provider_type()) {
continue;
}
for item in provider.items() {
if let Some(scored) = score_item(item) {
results.push(scored);
}
}
}
// Search static native providers
for provider in &self.static_native_providers {
if !filter.is_active(provider.provider_type()) {
continue;
}
for item in provider.items() {
if let Some(scored) = score_item(item) {
results.push(scored);
}
}
}
results.sort_by(|a, b| b.1.cmp(&a.1));
results.truncate(max_results);
#[cfg(feature = "dev-logging")]
{
debug!("[Search] Returning {} results", results.len());
for (i, (item, score)) in results.iter().take(5).enumerate() {
debug!("[Search] #{}: {} (score={}, provider={:?})", i + 1, item.name, score, item.provider);
}
if results.len() > 5 {
debug!("[Search] ... and {} more", results.len() - 5);
}
}
results
}
/// Get all available provider types (for UI tabs)
#[allow(dead_code)]
pub fn available_providers(&self) -> Vec<ProviderType> {
self.providers
.iter()
.map(|p| p.provider_type())
.chain(self.static_native_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 static native providers (clipboard, emoji, ssh, systemd, etc.)
for provider in &self.static_native_providers {
if provider.type_id() == plugin_id {
let actions = provider.query(&submenu_query);
if !actions.is_empty() {
return Some((display_name.to_string(), actions));
}
}
}
// Search in dynamic providers
for provider in &self.dynamic_providers {
if provider.type_id() == plugin_id {
let actions = provider.query(&submenu_query);
if !actions.is_empty() {
return Some((display_name.to_string(), actions));
}
}
}
// Search in widget providers
for provider in &self.widget_providers {
if provider.type_id() == plugin_id {
let actions = provider.query(&submenu_query);
if !actions.is_empty() {
return Some((display_name.to_string(), actions));
}
}
}
#[cfg(feature = "dev-logging")]
debug!("[Submenu] No submenu actions found for plugin '{}'", plugin_id);
None
}
}

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

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

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
}

File diff suppressed because it is too large Load Diff

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;

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