Compare commits
59 Commits
v0.4.3
...
owlry-core
| Author | SHA1 | Date | |
|---|---|---|---|
| 91da177f46 | |||
| f5d83f1372 | |||
| 50caa1ff0d | |||
| 0c46082b2b | |||
| a0b65e69a4 | |||
| 938a9ee6f3 | |||
| d4f71cae42 | |||
| 6391711df2 | |||
| 30b2b5b9c0 | |||
| 5be21aadc6 | |||
| 4ed9a9973a | |||
| 18c58ce33d | |||
| f609ce1c13 | |||
| 915dc193d9 | |||
| 71d78ce7df | |||
| 1bce5850a3 | |||
| 182a500596 | |||
| d79c9087fd | |||
| 8494a806bf | |||
| 9db3be6fdc | |||
| a49f5127dc | |||
| c0ea40a393 | |||
| 44f0915ba9 | |||
| a55567b422 | |||
| 707caefadf | |||
| 78895d34b5 | |||
| e6f217f19c | |||
| ff04675417 | |||
| b85f85c4da | |||
| 1aa92ee1e5 | |||
| 9532b3cfde | |||
| 551e5d74ae | |||
| 60eaffb2ab | |||
| 6d8d4a9f89 | |||
| 3ef9398655 | |||
| 46bb4bfb38 | |||
| c8aed5faf5 | |||
| bf8a31af78 | |||
| e23bdf5cee | |||
| 25c4d40d36 | |||
| b36dd2a438 | |||
| 35a0f580c3 | |||
| 7ed36c58c2 | |||
| 7cccd3b512 | |||
| 9f6d0c5935 | |||
| 026a232e0c | |||
| 1557119448 | |||
| b814d07382 | |||
| 0dead603ec | |||
| c1eb5ae2eb | |||
| 07847c76d8 | |||
| 2dfce67f3b | |||
| b1198f4600 | |||
| e6776b803c | |||
| 6e2d60466b | |||
| 8c1cf88474 | |||
| ecaaae39e3 | |||
| 96e9b09a31 | |||
| e053f7d5d5 |
411
CLAUDE.md
Normal file
@@ -0,0 +1,411 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Build & Development Commands
|
||||
|
||||
```bash
|
||||
just build # Debug build (all workspace members)
|
||||
just build-ui # UI binary only
|
||||
just build-daemon # Core daemon only
|
||||
just release # Release build (LTO, stripped)
|
||||
just release-daemon # Release build for daemon only
|
||||
just check # cargo check + clippy
|
||||
just test # Run tests
|
||||
just fmt # Format code
|
||||
just run [ARGS] # Run UI with optional args (e.g., just run --mode app)
|
||||
just run-daemon # Run core daemon
|
||||
just install-local # Install core + daemon + runtimes + systemd units
|
||||
|
||||
# Dev build with verbose logging
|
||||
cargo run -p owlry --features dev-logging
|
||||
|
||||
# Build core without embedded Lua (smaller binary, uses external owlry-lua)
|
||||
cargo build -p owlry --release --no-default-features
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Invocation
|
||||
|
||||
The UI client connects to the `owlry-core` daemon via Unix socket IPC. Start the daemon first:
|
||||
|
||||
```bash
|
||||
# Start daemon (systemd recommended)
|
||||
systemctl --user enable --now owlry-core.service
|
||||
|
||||
# Or run directly
|
||||
owlry-core
|
||||
|
||||
# Then launch UI
|
||||
owlry # Launch with all providers
|
||||
owlry -m app # Applications only
|
||||
owlry -m cmd # PATH commands only
|
||||
owlry --profile dev # Use a named config profile
|
||||
owlry -m calc # Calculator plugin only (if installed)
|
||||
```
|
||||
|
||||
### dmenu Mode
|
||||
|
||||
dmenu mode runs locally without the daemon. Use `-m dmenu` with piped input for interactive selection. The selected item is printed to stdout (not executed), so pipe the output to execute it:
|
||||
|
||||
```bash
|
||||
# Screenshot menu (execute selected command)
|
||||
printf '%s\n' \
|
||||
"grimblast --notify copy screen" \
|
||||
"grimblast --notify copy area" \
|
||||
"grimblast --notify edit screen" \
|
||||
| owlry -m dmenu -p "Screenshot" \
|
||||
| sh
|
||||
|
||||
# Git branch checkout
|
||||
git branch | owlry -m dmenu -p "checkout" | xargs git checkout
|
||||
|
||||
# Kill a process
|
||||
ps -eo comm | sort -u | owlry -m dmenu -p "kill" | xargs pkill
|
||||
|
||||
# Select and open a project
|
||||
find ~/projects -maxdepth 1 -type d | owlry -m dmenu | xargs code
|
||||
```
|
||||
|
||||
### CLI Flags
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `-m`, `--mode MODE` | Start in single-provider mode (app, cmd, dmenu, calc, etc.) |
|
||||
| `--profile NAME` | Use a named profile from config (defines which modes to enable) |
|
||||
| `-p`, `--prompt TEXT` | Custom prompt text for the search input (dmenu mode) |
|
||||
|
||||
### Available Modes
|
||||
|
||||
| Mode | Description |
|
||||
|------|-------------|
|
||||
| `app` | Desktop applications |
|
||||
| `cmd` | PATH commands |
|
||||
| `dmenu` | Pipe-based selection (requires stdin, runs locally) |
|
||||
| `calc` | Calculator (plugin) |
|
||||
| `clip` | Clipboard history (plugin) |
|
||||
| `emoji` | Emoji picker (plugin) |
|
||||
| `ssh` | SSH hosts (plugin) |
|
||||
| `sys` | System actions (plugin) |
|
||||
| `bm` | Bookmarks (plugin) |
|
||||
| `file` | File search (plugin) |
|
||||
| `web` | Web search (plugin) |
|
||||
| `uuctl` | systemd user units (plugin) |
|
||||
|
||||
### Search Prefixes
|
||||
|
||||
Type these in the search box to filter by provider:
|
||||
|
||||
| Prefix | Provider | Example |
|
||||
|--------|----------|---------|
|
||||
| `:app` | Applications | `:app firefox` |
|
||||
| `:cmd` | PATH commands | `:cmd git` |
|
||||
| `:sys` | System actions | `:sys shutdown` |
|
||||
| `:ssh` | SSH hosts | `:ssh server` |
|
||||
| `:clip` | Clipboard | `:clip password` |
|
||||
| `:bm` | Bookmarks | `:bm github` |
|
||||
| `:emoji` | Emoji | `:emoji heart` |
|
||||
| `:calc` | Calculator | `:calc sqrt(16)` |
|
||||
| `:web` | Web search | `:web rust docs` |
|
||||
| `:file` | Files | `:file config` |
|
||||
| `:uuctl` | systemd | `:uuctl docker` |
|
||||
| `:tag:X` | Filter by tag | `:tag:development` |
|
||||
|
||||
### Trigger Prefixes
|
||||
|
||||
| Trigger | Provider | Example |
|
||||
|---------|----------|---------|
|
||||
| `=` | Calculator | `= 5+3` |
|
||||
| `?` | Web search | `? rust programming` |
|
||||
| `/` | File search | `/ .bashrc` |
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `Enter` | Launch selected item |
|
||||
| `Escape` | Close launcher / exit submenu |
|
||||
| `Up` / `Down` | Navigate results |
|
||||
| `Tab` | Cycle filter tabs |
|
||||
| `Shift+Tab` | Cycle tabs (reverse) |
|
||||
| `Ctrl+1..9` | Toggle tab by position |
|
||||
|
||||
### Plugin CLI
|
||||
|
||||
```bash
|
||||
owlry plugin list # List installed
|
||||
owlry plugin list --available # Show registry
|
||||
owlry plugin search "query" # Search registry
|
||||
owlry plugin install <name> # Install from registry
|
||||
owlry plugin install ./path # Install from local path
|
||||
owlry plugin remove <name> # Uninstall
|
||||
owlry plugin enable/disable <name> # Toggle
|
||||
owlry plugin create <name> # Create Lua plugin template
|
||||
owlry plugin create <name> -r rune # Create Rune plugin template
|
||||
owlry plugin validate ./path # Validate plugin structure
|
||||
owlry plugin run <id> <cmd> [args] # Run plugin CLI command
|
||||
owlry plugin commands <id> # List plugin commands
|
||||
owlry plugin runtimes # Show available runtimes
|
||||
```
|
||||
|
||||
## Release Workflow
|
||||
|
||||
Always use `just` for releases - do NOT manually edit Cargo.toml for version bumps:
|
||||
|
||||
```bash
|
||||
# Bump a single crate
|
||||
just bump-crate owlry-core 0.5.1
|
||||
|
||||
# Bump all crates to same version
|
||||
just bump-all 0.5.1
|
||||
|
||||
# Bump core UI only
|
||||
just bump 0.5.1
|
||||
|
||||
# Create and push release tag
|
||||
git push && just tag
|
||||
|
||||
# AUR package management
|
||||
just aur-update # Update core UI PKGBUILD
|
||||
just aur-update-pkg NAME # Update specific package (owlry-core, owlry-lua, etc.)
|
||||
just aur-update-all # Update all AUR packages
|
||||
just aur-publish # Publish core UI to AUR
|
||||
just aur-publish-all # Publish all AUR packages
|
||||
|
||||
# Version inspection
|
||||
just show-versions # List all crate versions
|
||||
just aur-status # Show AUR package versions and git status
|
||||
```
|
||||
|
||||
## AUR Packaging
|
||||
|
||||
The `aur/` directory contains PKGBUILDs for core packages:
|
||||
|
||||
| Category | Packages |
|
||||
|----------|----------|
|
||||
| Core UI | `owlry` |
|
||||
| Core Daemon | `owlry-core` |
|
||||
| Runtimes | `owlry-lua`, `owlry-rune` |
|
||||
| Meta-bundles | `owlry-meta-essentials`, `owlry-meta-widgets`, `owlry-meta-tools`, `owlry-meta-full` |
|
||||
|
||||
Plugin AUR packages are in the separate `owlry-plugins` repo at `somegit.dev/Owlibou/owlry-plugins`.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Client/Daemon Split
|
||||
|
||||
Owlry uses a client/daemon architecture:
|
||||
|
||||
- **`owlry`** (client): GTK4 UI that connects to the daemon via Unix socket IPC. Handles rendering, user input, and launching applications. In dmenu mode, runs a local `ProviderManager` directly (no daemon needed).
|
||||
- **`owlry-core`** (daemon): Headless background service that loads plugins, manages providers, handles fuzzy matching, frecency scoring, and serves queries over IPC. Runs as a systemd user service.
|
||||
|
||||
### Workspace Structure
|
||||
|
||||
```
|
||||
owlry/
|
||||
├── Cargo.toml # Workspace root
|
||||
├── systemd/ # systemd user service/socket files
|
||||
│ ├── owlry-core.service
|
||||
│ └── owlry-core.socket
|
||||
├── crates/
|
||||
│ ├── owlry/ # UI client binary (GTK4 + Layer Shell)
|
||||
│ │ └── src/
|
||||
│ │ ├── main.rs # Entry point
|
||||
│ │ ├── app.rs # GTK Application setup, CSS loading
|
||||
│ │ ├── cli.rs # Clap CLI argument parsing
|
||||
│ │ ├── client.rs # CoreClient - IPC client to daemon
|
||||
│ │ ├── backend.rs # SearchBackend - abstraction over IPC/local
|
||||
│ │ ├── theme.rs # Theme loading
|
||||
│ │ ├── plugin_commands.rs # Plugin CLI subcommand handlers
|
||||
│ │ ├── providers/ # dmenu provider (local-only)
|
||||
│ │ └── ui/ # GTK widgets (MainWindow, ResultRow, submenu)
|
||||
│ ├── owlry-core/ # Daemon library + binary
|
||||
│ │ └── src/
|
||||
│ │ ├── main.rs # Daemon entry point
|
||||
│ │ ├── lib.rs # Public API (re-exports modules)
|
||||
│ │ ├── server.rs # Unix socket IPC server
|
||||
│ │ ├── ipc.rs # Request/Response message types
|
||||
│ │ ├── filter.rs # ProviderFilter - mode/prefix filtering
|
||||
│ │ ├── paths.rs # XDG path utilities, socket path
|
||||
│ │ ├── notify.rs # Desktop notifications
|
||||
│ │ ├── config/ # Config loading (config.toml)
|
||||
│ │ ├── data/ # FrecencyStore
|
||||
│ │ ├── providers/ # Application, Command, native/lua provider hosts
|
||||
│ │ └── plugins/ # Plugin loading, manifests, registry, runtimes
|
||||
│ ├── owlry-plugin-api/ # ABI-stable plugin interface
|
||||
│ ├── owlry-lua/ # Lua script runtime (cdylib)
|
||||
│ └── owlry-rune/ # Rune script runtime (cdylib)
|
||||
```
|
||||
|
||||
### IPC Protocol
|
||||
|
||||
Communication uses newline-delimited JSON over a Unix domain socket at `$XDG_RUNTIME_DIR/owlry/owlry.sock`.
|
||||
|
||||
**Request types** (`owlry_core::ipc::Request`):
|
||||
|
||||
| Type | Purpose |
|
||||
|------|---------|
|
||||
| `Query` | Search with text and optional mode filters |
|
||||
| `Launch` | Record a launch event for frecency |
|
||||
| `Providers` | List available providers |
|
||||
| `Refresh` | Refresh a specific provider |
|
||||
| `Toggle` | Toggle visibility (client-side concern, daemon acks) |
|
||||
| `Submenu` | Query submenu actions for a plugin item |
|
||||
| `PluginAction` | Execute a plugin action command |
|
||||
|
||||
**Response types** (`owlry_core::ipc::Response`):
|
||||
|
||||
| Type | Purpose |
|
||||
|------|---------|
|
||||
| `Results` | Search results with `Vec<ResultItem>` |
|
||||
| `Providers` | Provider list with `Vec<ProviderDesc>` |
|
||||
| `SubmenuItems` | Submenu actions for a plugin |
|
||||
| `Ack` | Success acknowledgement |
|
||||
| `Error` | Error with message |
|
||||
|
||||
### Core Data Flow
|
||||
|
||||
```
|
||||
[owlry UI] [owlry-core daemon]
|
||||
|
||||
main.rs → CliArgs → OwlryApp main.rs → Server::bind()
|
||||
↓ ↓
|
||||
SearchBackend UnixListener accept loop
|
||||
↓ ↓
|
||||
┌──────┴──────┐ handle_request()
|
||||
↓ ↓ ↓
|
||||
Daemon Local (dmenu) ┌───────────┴───────────┐
|
||||
↓ ↓ ↓
|
||||
CoreClient ──── IPC ────→ ProviderManager ProviderFilter
|
||||
↓ ↓
|
||||
[Provider impls] parse_query()
|
||||
↓
|
||||
LaunchItem[]
|
||||
↓
|
||||
FrecencyStore (boost)
|
||||
↓
|
||||
Response::Results ──── IPC ────→ UI rendering
|
||||
```
|
||||
|
||||
### Provider System
|
||||
|
||||
**Core providers** (in `owlry-core`):
|
||||
- **Application**: Desktop applications from XDG directories
|
||||
- **Command**: Shell commands from PATH
|
||||
|
||||
**dmenu provider** (in `owlry` client, local only):
|
||||
- **Dmenu**: Pipe-based input (dmenu compatibility)
|
||||
|
||||
All other providers are native plugins in the separate `owlry-plugins` repo (`somegit.dev/Owlibou/owlry-plugins`).
|
||||
|
||||
`ProviderManager` (in `owlry-core`) orchestrates providers and handles:
|
||||
- Fuzzy matching via `SkimMatcherV2`
|
||||
- Frecency score boosting
|
||||
- Native plugin loading from `/usr/lib/owlry/plugins/`
|
||||
|
||||
**Submenu System**: Plugins can return items with `SUBMENU:plugin_id:data` commands. When selected, the plugin is queried with `?SUBMENU:data` to get action items (e.g., systemd service actions).
|
||||
|
||||
### Plugin API
|
||||
|
||||
Native plugins use the ABI-stable interface in `owlry-plugin-api`:
|
||||
|
||||
```rust
|
||||
#[repr(C)]
|
||||
pub struct PluginVTable {
|
||||
pub info: extern "C" fn() -> PluginInfo,
|
||||
pub providers: extern "C" fn() -> RVec<ProviderInfo>,
|
||||
pub provider_init: extern "C" fn(id: RStr) -> ProviderHandle,
|
||||
pub provider_refresh: extern "C" fn(ProviderHandle) -> RVec<PluginItem>,
|
||||
pub provider_query: extern "C" fn(ProviderHandle, RStr) -> RVec<PluginItem>,
|
||||
pub provider_drop: extern "C" fn(ProviderHandle),
|
||||
}
|
||||
|
||||
// Each plugin exports:
|
||||
#[no_mangle]
|
||||
pub extern "C" fn owlry_plugin_vtable() -> &'static PluginVTable
|
||||
```
|
||||
|
||||
Plugins are compiled as `.so` (cdylib) and loaded by the daemon at startup.
|
||||
|
||||
**Plugin locations** (when deployed):
|
||||
- `/usr/lib/owlry/plugins/*.so` - Native plugins
|
||||
- `/usr/lib/owlry/runtimes/*.so` - Script runtimes (liblua.so, librune.so)
|
||||
- `~/.config/owlry/plugins/` - User plugins (Lua/Rune)
|
||||
|
||||
### Filter & Prefix System
|
||||
|
||||
`ProviderFilter` (`owlry-core/src/filter.rs`) handles:
|
||||
- CLI mode selection (`--mode app`)
|
||||
- Profile-based mode selection (`--profile dev`)
|
||||
- Provider toggling (Ctrl+1/2/3)
|
||||
- Prefix parsing (`:app`, `:cmd`, `:sys`, etc.)
|
||||
|
||||
Query parsing extracts prefix and forwards clean query to providers.
|
||||
|
||||
### SearchBackend
|
||||
|
||||
`SearchBackend` (`owlry/src/backend.rs`) abstracts over two modes:
|
||||
- **`Daemon`**: Wraps `CoreClient`, sends queries over IPC to `owlry-core`
|
||||
- **`Local`**: Wraps `ProviderManager` directly (used for dmenu mode only)
|
||||
|
||||
### UI Layer
|
||||
|
||||
- `MainWindow` (`src/ui/main_window.rs`): GTK4 window with Layer Shell overlay
|
||||
- `ResultRow` (`src/ui/result_row.rs`): Individual result rendering
|
||||
- `submenu` (`src/ui/submenu.rs`): Universal submenu parsing utilities (plugins provide actions)
|
||||
|
||||
### Configuration
|
||||
|
||||
`Config` (`owlry-core/src/config/mod.rs`) loads from `~/.config/owlry/config.toml`:
|
||||
- Auto-detects terminal (`$TERMINAL` -> `xdg-terminal-exec` -> common terminals)
|
||||
- Optional `use_uwsm = true` for systemd session integration (launches apps via `uwsm app --`)
|
||||
- Profiles: Define named mode sets under `[profiles.<name>]` with `modes = ["app", "cmd", ...]`
|
||||
|
||||
### Theming
|
||||
|
||||
CSS loading priority (`owlry/src/app.rs`):
|
||||
1. Base structural CSS (`resources/base.css`)
|
||||
2. Theme CSS (built-in "owl" or custom `~/.config/owlry/themes/{name}.css`)
|
||||
3. User overrides (`~/.config/owlry/style.css`)
|
||||
4. Config variable injection
|
||||
|
||||
### Systemd Integration
|
||||
|
||||
Service files in `systemd/`:
|
||||
- `owlry-core.service`: Runs daemon as `Type=simple`, restarts on failure
|
||||
- `owlry-core.socket`: Socket activation at `%t/owlry/owlry.sock`
|
||||
|
||||
Start with: `systemctl --user enable --now owlry-core.service`
|
||||
|
||||
## Plugins
|
||||
|
||||
Plugins live in a separate repository: `somegit.dev/Owlibou/owlry-plugins`
|
||||
|
||||
13 native plugin crates, all compiled as cdylib (.so):
|
||||
|
||||
| Category | Plugins | Behavior |
|
||||
|----------|---------|----------|
|
||||
| Static | bookmarks, clipboard, emoji, scripts, ssh, system, systemd | Loaded at startup, refresh() populates items |
|
||||
| Dynamic | calculator, websearch, filesearch | Queried per-keystroke via query() |
|
||||
| Widget | weather, media, pomodoro | Displayed at top of results |
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- **Rc<RefCell<T>>** used throughout for GTK signal handlers needing mutable state
|
||||
- **Feature flag `dev-logging`**: Wraps debug!() calls in `#[cfg(feature = "dev-logging")]`
|
||||
- **Feature flag `lua`**: Enables built-in Lua runtime (off by default); enable to embed Lua in core binary
|
||||
- **dmenu mode**: Runs locally without daemon. Use `-m dmenu` with piped stdin
|
||||
- **Frecency**: Time-decayed frequency scoring stored in `~/.local/share/owlry/frecency.json`
|
||||
- **ABI stability**: Plugin interface uses `abi_stable` crate for safe Rust dynamic linking
|
||||
- **Plugin API v3**: Adds `position` (Normal/Widget) and `priority` fields to ProviderInfo
|
||||
- **ProviderType simplification**: Core uses only `Application`, `Command`, `Dmenu`, `Plugin(String)` - all plugin-specific types removed from core
|
||||
|
||||
## Dependencies (Rust 1.90+, GTK 4.12+)
|
||||
|
||||
External tool dependencies (for plugins):
|
||||
- Clipboard plugin: `cliphist`, `wl-clipboard`
|
||||
- File search plugin: `fd` or `mlocate`
|
||||
- Emoji plugin: `wl-clipboard`, `noto-fonts-emoji`
|
||||
- Systemd plugin: `systemd` (user services)
|
||||
- Bookmarks plugin: Firefox support uses `rusqlite` with bundled SQLite (no system dependency)
|
||||
2073
Cargo.lock
generated
14
Cargo.toml
@@ -2,20 +2,8 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/owlry",
|
||||
"crates/owlry-core",
|
||||
"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",
|
||||
]
|
||||
|
||||
254
README.md
@@ -10,12 +10,15 @@ A lightweight, owl-themed application launcher for Wayland, built with GTK4 and
|
||||
|
||||
## Features
|
||||
|
||||
- **Client/daemon architecture** — Instant window appearance, providers stay loaded in memory
|
||||
- **Modular plugin architecture** — Install only what you need
|
||||
- **Fuzzy search with tags** — Fast matching across names, descriptions, and category tags
|
||||
- **13 native plugins** — Calculator, clipboard, emoji, weather, media, and more
|
||||
- **Widget providers** — Weather, media controls, and pomodoro timer at the top of results
|
||||
- **Config profiles** — Named mode presets for different workflows
|
||||
- **Filter prefixes** — Scope searches with `:app`, `:cmd`, `:tag:development`, etc.
|
||||
- **Frecency ranking** — Frequently/recently used items rank higher
|
||||
- **Toggle behavior** — Bind one key to open/close the launcher
|
||||
- **GTK4 theming** — System theme by default, with 9 built-in themes
|
||||
- **Wayland native** — Uses Layer Shell for proper overlay behavior
|
||||
- **Extensible** — Create custom plugins in Lua or Rune
|
||||
@@ -32,10 +35,10 @@ yay -S owlry
|
||||
yay -S owlry-plugin-calculator owlry-plugin-weather
|
||||
|
||||
# Or install bundles:
|
||||
yay -S owlry-essentials # calculator, system, ssh, scripts, bookmarks
|
||||
yay -S owlry-widgets # weather, media, pomodoro
|
||||
yay -S owlry-tools # clipboard, emoji, websearch, filesearch, systemd
|
||||
yay -S owlry-full # everything
|
||||
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
|
||||
@@ -46,14 +49,14 @@ yay -S owlry-rune # Rune runtime
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `owlry` | Core binary with applications and commands |
|
||||
| `owlry` | Core: UI client (`owlry`) and daemon (`owlry-core`) |
|
||||
| `owlry-plugin-calculator` | Math expressions (`= 5+3`) |
|
||||
| `owlry-plugin-system` | Shutdown, reboot, suspend, lock |
|
||||
| `owlry-plugin-ssh` | SSH hosts from `~/.ssh/config` |
|
||||
| `owlry-plugin-clipboard` | History via cliphist |
|
||||
| `owlry-plugin-emoji` | 400+ searchable emoji |
|
||||
| `owlry-plugin-scripts` | User scripts |
|
||||
| `owlry-plugin-bookmarks` | Chrome, Brave, Edge bookmarks |
|
||||
| `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 |
|
||||
@@ -80,8 +83,8 @@ sudo dnf install gtk4-devel gtk4-layer-shell-devel
|
||||
git clone https://somegit.dev/Owlibou/owlry.git
|
||||
cd owlry
|
||||
|
||||
# Build core only
|
||||
cargo build --release -p owlry
|
||||
# Build core only (daemon + UI)
|
||||
cargo build --release -p owlry -p owlry-core
|
||||
|
||||
# Build specific plugin
|
||||
cargo build --release -p owlry-plugin-calculator
|
||||
@@ -90,21 +93,137 @@ cargo build --release -p owlry-plugin-calculator
|
||||
cargo build --release --workspace
|
||||
```
|
||||
|
||||
**Install plugins manually:**
|
||||
**Install locally:**
|
||||
```bash
|
||||
sudo mkdir -p /usr/lib/owlry/plugins
|
||||
sudo cp target/release/libowlry_plugin_*.so /usr/lib/owlry/plugins/
|
||||
just install-local
|
||||
```
|
||||
|
||||
This installs both binaries, all plugins, runtimes, and the systemd service files.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Owlry uses a client/daemon architecture. The daemon (`owlry-core`) loads providers and plugins into memory. The UI client (`owlry`) connects to the daemon over a Unix socket for instant results.
|
||||
|
||||
### Starting the Daemon
|
||||
|
||||
Choose one of three methods:
|
||||
|
||||
**1. Compositor autostart (recommended for most users)**
|
||||
|
||||
Add to your compositor config:
|
||||
|
||||
```bash
|
||||
# Hyprland (~/.config/hypr/hyprland.conf)
|
||||
exec-once = owlry-core
|
||||
|
||||
# Sway (~/.config/sway/config)
|
||||
exec owlry-core
|
||||
```
|
||||
|
||||
**2. Systemd user service**
|
||||
|
||||
```bash
|
||||
systemctl --user enable --now owlry-core.service
|
||||
```
|
||||
|
||||
**3. Socket activation (auto-start on first use)**
|
||||
|
||||
```bash
|
||||
systemctl --user enable owlry-core.socket
|
||||
```
|
||||
|
||||
The daemon starts automatically when the UI client first connects. No manual startup needed.
|
||||
|
||||
### Launching the UI
|
||||
|
||||
Bind `owlry` to a key in your compositor:
|
||||
|
||||
```bash
|
||||
# Hyprland
|
||||
bind = SUPER, Space, exec, owlry
|
||||
|
||||
# Sway
|
||||
bindsym $mod+space exec owlry
|
||||
```
|
||||
|
||||
Running `owlry` a second time while it is already open sends a toggle command — the window closes. This means a single keybind acts as open/close.
|
||||
|
||||
If the daemon is not running when the UI launches, it will attempt to start it via systemd automatically.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
owlry # Launch with defaults
|
||||
owlry --mode app # Applications only
|
||||
owlry --providers app,cmd # Specific providers
|
||||
owlry --help # Show all options
|
||||
owlry # Launch with all providers
|
||||
owlry -m app # Applications only
|
||||
owlry -m cmd # PATH commands only
|
||||
owlry -m calc # Calculator plugin only (if installed)
|
||||
owlry --profile dev # Use a named profile from config
|
||||
owlry --help # Show all options with examples
|
||||
```
|
||||
|
||||
### Profiles
|
||||
|
||||
Profiles are named sets of modes defined in your config:
|
||||
|
||||
```toml
|
||||
[profiles.dev]
|
||||
modes = ["app", "cmd", "ssh"]
|
||||
|
||||
[profiles.media]
|
||||
modes = ["media", "emoji"]
|
||||
|
||||
[profiles.minimal]
|
||||
modes = ["app"]
|
||||
```
|
||||
|
||||
Launch with a profile:
|
||||
|
||||
```bash
|
||||
owlry --profile dev
|
||||
```
|
||||
|
||||
You can bind different profiles to different keys:
|
||||
|
||||
```bash
|
||||
# Hyprland
|
||||
bind = SUPER, Space, exec, owlry
|
||||
bind = SUPER, D, exec, owlry --profile dev
|
||||
bind = SUPER, M, exec, owlry --profile media
|
||||
```
|
||||
|
||||
### dmenu Mode
|
||||
|
||||
Owlry is dmenu-compatible. Pipe input for interactive selection — the selected item is printed to stdout (not executed), so you pipe the output to execute it.
|
||||
|
||||
dmenu mode is self-contained: it does not use the daemon and works without `owlry-core` running.
|
||||
|
||||
```bash
|
||||
# Screenshot menu (execute selected command)
|
||||
printf '%s\n' \
|
||||
"grimblast --notify copy screen" \
|
||||
"grimblast --notify copy area" \
|
||||
"grimblast --notify edit screen" \
|
||||
| owlry -m dmenu -p "Screenshot" \
|
||||
| sh
|
||||
|
||||
# Git branch checkout
|
||||
git branch | owlry -m dmenu -p "checkout" | xargs git checkout
|
||||
|
||||
# Kill a process
|
||||
ps -eo comm | sort -u | owlry -m dmenu -p "kill" | xargs pkill
|
||||
|
||||
# Select and open a project
|
||||
find ~/projects -maxdepth 1 -type d | owlry -m dmenu | xargs code
|
||||
|
||||
# Package manager search
|
||||
pacman -Ssq | owlry -m dmenu -p "install" | xargs sudo pacman -S
|
||||
|
||||
# Open selected file
|
||||
ls ~/Documents | owlry -m dmenu | xargs xdg-open
|
||||
```
|
||||
|
||||
The `-p` / `--prompt` flag sets a custom label for the search input.
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
@@ -158,6 +277,21 @@ Owlry follows the [XDG Base Directory Specification](https://specifications.free
|
||||
| `~/.local/share/owlry/scripts/` | User scripts |
|
||||
| `~/.local/share/owlry/frecency.json` | Usage history |
|
||||
|
||||
System locations:
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `/usr/lib/owlry/plugins/*.so` | Installed native plugins |
|
||||
| `/usr/lib/owlry/runtimes/*.so` | Lua/Rune script runtimes |
|
||||
| `/usr/share/doc/owlry/config.example.toml` | Example configuration |
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# Copy example config
|
||||
mkdir -p ~/.config/owlry
|
||||
cp /usr/share/doc/owlry/config.example.toml ~/.config/owlry/config.toml
|
||||
```
|
||||
|
||||
### Example Configuration
|
||||
|
||||
```toml
|
||||
@@ -165,12 +299,12 @@ Owlry follows the [XDG Base Directory Specification](https://specifications.free
|
||||
show_icons = true
|
||||
max_results = 10
|
||||
tabs = ["app", "cmd", "uuctl"]
|
||||
# terminal_command = "kitty" # Auto-detected
|
||||
# launch_wrapper = "uwsm app --" # Auto-detected
|
||||
# terminal_command = "kitty" # Auto-detected
|
||||
# use_uwsm = false # Enable for systemd session integration
|
||||
|
||||
[appearance]
|
||||
width = 700
|
||||
height = 500
|
||||
width = 850
|
||||
height = 650
|
||||
font_size = 14
|
||||
border_radius = 12
|
||||
# theme = "owl" # Or: catppuccin-mocha, nord, dracula, etc.
|
||||
@@ -178,20 +312,28 @@ border_radius = 12
|
||||
[plugins]
|
||||
disabled = [] # Plugin IDs to disable, e.g., ["emoji", "pomodoro"]
|
||||
|
||||
# Per-plugin configuration (new in 0.4.0)
|
||||
[plugins.weather]
|
||||
provider = "wttr.in" # or: openweathermap, open-meteo
|
||||
location = "Berlin" # city name or "lat,lon"
|
||||
# api_key = "..." # Required for OpenWeatherMap
|
||||
[providers]
|
||||
applications = true # .desktop files
|
||||
commands = true # PATH executables
|
||||
frecency = true # Boost frequently used items
|
||||
frecency_weight = 0.3 # 0.0-1.0
|
||||
|
||||
[plugins.pomodoro]
|
||||
work_mins = 25 # Work session duration
|
||||
break_mins = 5 # Break duration
|
||||
# Web search engine: google, duckduckgo, bing, startpage, brave, ecosia
|
||||
search_engine = "duckduckgo"
|
||||
|
||||
# Profiles: named sets of modes
|
||||
[profiles.dev]
|
||||
modes = ["app", "cmd", "ssh"]
|
||||
|
||||
[profiles.media]
|
||||
modes = ["media", "emoji"]
|
||||
```
|
||||
|
||||
See `/usr/share/doc/owlry/config.example.toml` for all options with documentation.
|
||||
|
||||
## Plugin System
|
||||
|
||||
Owlry uses a modular plugin architecture. Plugins are loaded from:
|
||||
Owlry uses a modular plugin architecture. Plugins are loaded by the daemon (`owlry-core`) from:
|
||||
|
||||
- `/usr/lib/owlry/plugins/*.so` — System plugins (AUR packages)
|
||||
- `~/.config/owlry/plugins/` — User plugins (requires `owlry-lua` or `owlry-rune`)
|
||||
@@ -205,6 +347,38 @@ Add plugin IDs to the disabled list in your config:
|
||||
disabled = ["emoji", "pomodoro"]
|
||||
```
|
||||
|
||||
### Plugin Management CLI
|
||||
|
||||
```bash
|
||||
# List installed plugins
|
||||
owlry plugin list
|
||||
owlry plugin list --enabled # Only enabled
|
||||
owlry plugin list --available # Show registry plugins
|
||||
|
||||
# Search registry
|
||||
owlry plugin search "weather"
|
||||
|
||||
# Install/remove
|
||||
owlry plugin install <name> # From registry
|
||||
owlry plugin install ./my-plugin # From local path
|
||||
owlry plugin remove <name>
|
||||
|
||||
# Enable/disable
|
||||
owlry plugin enable <name>
|
||||
owlry plugin disable <name>
|
||||
|
||||
# Plugin info
|
||||
owlry plugin info <name>
|
||||
owlry plugin commands <name> # List plugin CLI commands
|
||||
|
||||
# Create new plugin
|
||||
owlry plugin create my-plugin # Lua (default)
|
||||
owlry plugin create my-plugin -r rune # Rune
|
||||
|
||||
# Run plugin command
|
||||
owlry plugin run <plugin-id> <command> [args...]
|
||||
```
|
||||
|
||||
### Creating Custom Plugins
|
||||
|
||||
See [docs/PLUGIN_DEVELOPMENT.md](docs/PLUGIN_DEVELOPMENT.md) for:
|
||||
@@ -264,17 +438,25 @@ Create `~/.config/owlry/themes/mytheme.css`:
|
||||
|
||||
## Architecture
|
||||
|
||||
Owlry uses a client/daemon split:
|
||||
|
||||
```
|
||||
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)
|
||||
owlry-core (daemon) owlry (GTK4 UI client)
|
||||
├── Loads config + plugins ├── Connects to daemon via Unix socket
|
||||
├── Applications provider ├── Renders results in GTK4 window
|
||||
├── Commands provider ├── Handles keyboard input
|
||||
├── Plugin loader ├── Toggle: second launch closes window
|
||||
│ ├── /usr/lib/owlry/plugins/*.so └── dmenu mode (self-contained, no daemon)
|
||||
│ ├── /usr/lib/owlry/runtimes/
|
||||
│ └── ~/.config/owlry/plugins/
|
||||
├── Frecency tracking
|
||||
└── IPC server (Unix socket)
|
||||
│
|
||||
└── $XDG_RUNTIME_DIR/owlry/owlry.sock
|
||||
```
|
||||
|
||||
The daemon keeps providers and plugins loaded in memory, so the UI appears instantly when launched. The UI client is a thin GTK4 layer that sends queries and receives results over the socket.
|
||||
|
||||
For detailed architecture information, see [CLAUDE.md](CLAUDE.md).
|
||||
|
||||
## License
|
||||
|
||||
109
ROADMAP.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# Owlry Roadmap
|
||||
|
||||
Feature ideas and future development plans for Owlry.
|
||||
|
||||
## High Value, Low Effort
|
||||
|
||||
### Plugin hot-reload
|
||||
Detect `.so` file changes in `/usr/lib/owlry/plugins/` and reload without restarting the launcher. The loader infrastructure already exists.
|
||||
|
||||
### Frecency pruning
|
||||
Add `max_entries` and `max_age_days` config options. Prune old entries on startup to prevent `frecency.json` from growing unbounded.
|
||||
|
||||
### `:recent` prefix
|
||||
Show last N launched items. Data already exists in frecency.json — just needs a provider to surface it.
|
||||
|
||||
### Clipboard images
|
||||
`cliphist` supports images. Extend the clipboard plugin to show image thumbnails in results.
|
||||
|
||||
---
|
||||
|
||||
## Medium Effort, High Value
|
||||
|
||||
### Actions on any result
|
||||
Generalize the submenu system beyond systemd. Every result type gets contextual actions:
|
||||
|
||||
| Provider | Actions |
|
||||
|----------|---------|
|
||||
| Applications | Open, Open in terminal, Show .desktop location |
|
||||
| Files | Open, Open folder, Copy path, Delete |
|
||||
| SSH | Connect, Copy hostname, Edit config |
|
||||
| Bookmarks | Open, Copy URL, Open incognito |
|
||||
| Clipboard | Paste, Delete from history |
|
||||
|
||||
This is the difference between a launcher and a command palette.
|
||||
|
||||
### Plugin settings UI
|
||||
A `:settings` provider that lists installed plugins and their configurable options. Edit values inline, writes to `config.toml`.
|
||||
|
||||
### Result action capture
|
||||
Calculator shows `= 5+3 → 8`. Allow pressing Tab or Ctrl+C to copy the result to clipboard instead of "launching" it. Useful for calculator, file paths, URLs.
|
||||
|
||||
---
|
||||
|
||||
## Bigger Bets
|
||||
|
||||
### Window switcher with live thumbnails
|
||||
A `windows` plugin using Wayland screencopy to show live thumbnails of open windows. Hyprland and Sway expose window lists via IPC. Could replace Alt+Tab.
|
||||
|
||||
### Cross-device bookmark sync
|
||||
Firefox and Chrome sync bookmarks across devices. Parse sync metadata to show "recently added on other devices" or "bookmarks from phone".
|
||||
|
||||
### Natural language commands
|
||||
Parse simple natural language into system commands:
|
||||
|
||||
```
|
||||
"shutdown in 30 minutes" → systemd-run --user --on-active=30m systemctl poweroff
|
||||
"remind me in 1 hour" → notify-send scheduled via at/systemd timer
|
||||
"volume 50%" → wpctl set-volume @DEFAULT_AUDIO_SINK@ 0.5
|
||||
```
|
||||
|
||||
Local pattern matching, no AI/cloud required.
|
||||
|
||||
### Plugin marketplace
|
||||
A curated registry of third-party Lua/Rune plugins with one-command install:
|
||||
|
||||
```bash
|
||||
owlry plugin install github-notifications
|
||||
owlry plugin install todoist
|
||||
owlry plugin install spotify-controls
|
||||
```
|
||||
|
||||
The script runtimes make this viable without recompiling.
|
||||
|
||||
---
|
||||
|
||||
## Technical Debt
|
||||
|
||||
### Split monorepo for user build efficiency
|
||||
Currently, a small core fix requires all 16 AUR packages to rebuild (same source tarball). Split into 3 repos:
|
||||
|
||||
| Repo | Contents | Versioning |
|
||||
|------|----------|------------|
|
||||
| `owlry` | Core binary | Independent |
|
||||
| `owlry-plugin-api` | ABI interface (crates.io) | Semver, conservative |
|
||||
| `owlry-plugins` | 13 plugins + 2 runtimes | Independent per plugin |
|
||||
|
||||
**Execution order:**
|
||||
1. Publish `owlry-plugin-api` to crates.io
|
||||
2. Update monorepo to use crates.io dependency
|
||||
3. Create `owlry-plugins` repo, move plugins + runtimes
|
||||
4. Slim current repo to core-only
|
||||
5. Update AUR PKGBUILDs with new source URLs
|
||||
|
||||
**Benefit:** Core bugfix = 1 rebuild. Plugin fix = 1 rebuild. Third-party plugins possible via crates.io.
|
||||
|
||||
### Replace meval with evalexpr
|
||||
`meval` depends on `nom v1.2.4` which will be rejected by future Rust versions. Migrate calculator plugin and Lua runtime to `evalexpr` v13+.
|
||||
|
||||
### Plugin API backwards compatibility
|
||||
When `API_VERSION` increments, provide a compatibility shim so v3 plugins work with v4 core. Prevents ecosystem fragmentation.
|
||||
|
||||
### Per-plugin configuration
|
||||
Current flat `[providers]` config doesn't scale. Design a `[plugins.weather]`, `[plugins.pomodoro]` structure that plugins can declare and the core validates.
|
||||
|
||||
---
|
||||
|
||||
## Priority
|
||||
|
||||
If we had to pick one: **Actions on any result**. It transforms every provider from "search and launch" to "search and do anything". The ROI is massive.
|
||||
58
crates/owlry-core/Cargo.toml
Normal file
@@ -0,0 +1,58 @@
|
||||
[package]
|
||||
name = "owlry-core"
|
||||
version = "1.0.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Core daemon for the Owlry application launcher"
|
||||
|
||||
[lib]
|
||||
name = "owlry_core"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "owlry-core"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# Provider system
|
||||
fuzzy-matcher = "0.3"
|
||||
freedesktop-desktop-entry = "0.8"
|
||||
|
||||
# Plugin loading
|
||||
libloading = "0.8"
|
||||
semver = "1"
|
||||
|
||||
# Data & config
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "0.8"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
dirs = "5"
|
||||
|
||||
# Error handling
|
||||
thiserror = "2"
|
||||
|
||||
# Signal handling
|
||||
ctrlc = { version = "3", features = ["termination"] }
|
||||
|
||||
# Logging & notifications
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
notify-rust = "4"
|
||||
|
||||
# Optional: embedded Lua runtime
|
||||
mlua = { version = "0.11", features = ["lua54", "vendored", "send", "serialize"], optional = true }
|
||||
meval = { version = "0.2", optional = true }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "blocking"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
lua = ["dep:mlua", "dep:meval", "dep:reqwest"]
|
||||
dev-logging = []
|
||||
@@ -6,6 +6,21 @@ use std::process::Command;
|
||||
|
||||
use crate::paths;
|
||||
|
||||
/// A named profile that selects a set of provider modes.
|
||||
///
|
||||
/// Defined in config.toml as:
|
||||
/// ```toml
|
||||
/// [profiles.dev]
|
||||
/// modes = ["app", "cmd", "ssh"]
|
||||
///
|
||||
/// [profiles.media]
|
||||
/// modes = ["media", "emoji"]
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
pub struct ProfileConfig {
|
||||
pub modes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Config {
|
||||
#[serde(default)]
|
||||
@@ -16,6 +31,8 @@ pub struct Config {
|
||||
pub providers: ProvidersConfig,
|
||||
#[serde(default)]
|
||||
pub plugins: PluginsConfig,
|
||||
#[serde(default)]
|
||||
pub profiles: HashMap<String, ProfileConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -27,11 +44,12 @@ pub struct GeneralConfig {
|
||||
/// 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
|
||||
/// Enable uwsm (Universal Wayland Session Manager) for launching apps.
|
||||
/// When enabled, desktop files are launched via `uwsm app -- <file>`
|
||||
/// which starts apps in a proper systemd user session.
|
||||
/// When disabled (default), apps are launched via `gio launch`.
|
||||
#[serde(default)]
|
||||
pub launch_wrapper: Option<String>,
|
||||
pub use_uwsm: bool,
|
||||
/// Provider tabs shown in the header bar.
|
||||
/// Valid values: app, cmd, uuctl, bookmark, calc, clip, dmenu, emoji, file, script, ssh, sys, web
|
||||
#[serde(default = "default_tabs")]
|
||||
@@ -44,7 +62,7 @@ impl Default for GeneralConfig {
|
||||
show_icons: true,
|
||||
max_results: 100,
|
||||
terminal_command: None,
|
||||
launch_wrapper: None,
|
||||
use_uwsm: false,
|
||||
tabs: default_tabs(),
|
||||
}
|
||||
}
|
||||
@@ -55,11 +73,7 @@ fn default_max_results() -> usize {
|
||||
}
|
||||
|
||||
fn default_tabs() -> Vec<String> {
|
||||
vec![
|
||||
"app".to_string(),
|
||||
"cmd".to_string(),
|
||||
"uuctl".to_string(),
|
||||
]
|
||||
vec!["app".to_string(), "cmd".to_string(), "uuctl".to_string()]
|
||||
}
|
||||
|
||||
/// User-customizable theme colors
|
||||
@@ -125,10 +139,18 @@ impl Default for AppearanceConfig {
|
||||
}
|
||||
}
|
||||
|
||||
fn default_width() -> i32 { 850 }
|
||||
fn default_height() -> i32 { 650 }
|
||||
fn default_font_size() -> u32 { 14 }
|
||||
fn default_border_radius() -> u32 { 12 }
|
||||
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 {
|
||||
@@ -178,7 +200,6 @@ pub struct ProvidersConfig {
|
||||
pub files: bool,
|
||||
|
||||
// ─── Widget Providers ───────────────────────────────────────────────
|
||||
|
||||
/// Enable MPRIS media player widget
|
||||
#[serde(default = "default_true")]
|
||||
pub media: bool,
|
||||
@@ -332,28 +353,19 @@ impl PluginsConfig {
|
||||
/// 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()
|
||||
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()
|
||||
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()
|
||||
self.plugin_configs.get(plugin_name)?.get(key)?.as_bool()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,29 +408,6 @@ 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)
|
||||
@@ -431,10 +420,12 @@ fn detect_launch_wrapper() -> Option<String> {
|
||||
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;
|
||||
}
|
||||
&& !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") {
|
||||
@@ -458,7 +449,14 @@ fn detect_terminal() -> String {
|
||||
}
|
||||
|
||||
// 5. Common X11/legacy terminals
|
||||
let legacy_terminals = ["gnome-terminal", "konsole", "xfce4-terminal", "mate-terminal", "tilix", "terminator"];
|
||||
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);
|
||||
@@ -578,11 +576,6 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-detect launch wrapper if not configured
|
||||
if config.general.launch_wrapper.is_none() {
|
||||
config.general.launch_wrapper = detect_launch_wrapper();
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
632
crates/owlry-core/src/filter.rs
Normal file
@@ -0,0 +1,632 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
use log::debug;
|
||||
|
||||
use crate::config::ProvidersConfig;
|
||||
use crate::providers::ProviderType;
|
||||
|
||||
/// Tracks which providers are enabled and handles prefix-based filtering
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderFilter {
|
||||
enabled: HashSet<ProviderType>,
|
||||
active_prefix: Option<ProviderType>,
|
||||
/// When true, `is_active`/`is_enabled` accept any provider type
|
||||
/// (unless a prefix narrows the scope). Used by `all()` so that
|
||||
/// dynamically loaded plugins are accepted without being listed.
|
||||
accept_all: bool,
|
||||
}
|
||||
|
||||
/// Result of parsing a query for prefix syntax
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParsedQuery {
|
||||
pub prefix: Option<ProviderType>,
|
||||
pub tag_filter: Option<String>,
|
||||
pub query: String,
|
||||
}
|
||||
|
||||
impl ProviderFilter {
|
||||
/// Create filter from CLI args and config
|
||||
pub fn new(
|
||||
cli_mode: Option<ProviderType>,
|
||||
cli_providers: Option<Vec<ProviderType>>,
|
||||
config_providers: &ProvidersConfig,
|
||||
) -> Self {
|
||||
let enabled = if let Some(mode) = cli_mode {
|
||||
// --mode overrides everything: single provider
|
||||
HashSet::from([mode])
|
||||
} else if let Some(providers) = cli_providers {
|
||||
// --providers overrides config
|
||||
providers.into_iter().collect()
|
||||
} else {
|
||||
// Use config file settings, default to apps only
|
||||
let mut set = HashSet::new();
|
||||
// Core providers
|
||||
if config_providers.applications {
|
||||
set.insert(ProviderType::Application);
|
||||
}
|
||||
if config_providers.commands {
|
||||
set.insert(ProviderType::Command);
|
||||
}
|
||||
// Plugin providers - use Plugin(type_id) for all
|
||||
if config_providers.uuctl {
|
||||
set.insert(ProviderType::Plugin("uuctl".to_string()));
|
||||
}
|
||||
if config_providers.system {
|
||||
set.insert(ProviderType::Plugin("system".to_string()));
|
||||
}
|
||||
if config_providers.ssh {
|
||||
set.insert(ProviderType::Plugin("ssh".to_string()));
|
||||
}
|
||||
if config_providers.clipboard {
|
||||
set.insert(ProviderType::Plugin("clipboard".to_string()));
|
||||
}
|
||||
if config_providers.bookmarks {
|
||||
set.insert(ProviderType::Plugin("bookmarks".to_string()));
|
||||
}
|
||||
if config_providers.emoji {
|
||||
set.insert(ProviderType::Plugin("emoji".to_string()));
|
||||
}
|
||||
if config_providers.scripts {
|
||||
set.insert(ProviderType::Plugin("scripts".to_string()));
|
||||
}
|
||||
// Dynamic providers
|
||||
if config_providers.files {
|
||||
set.insert(ProviderType::Plugin("filesearch".to_string()));
|
||||
}
|
||||
if config_providers.calculator {
|
||||
set.insert(ProviderType::Plugin("calc".to_string()));
|
||||
}
|
||||
if config_providers.websearch {
|
||||
set.insert(ProviderType::Plugin("websearch".to_string()));
|
||||
}
|
||||
// Default to apps if nothing enabled
|
||||
if set.is_empty() {
|
||||
set.insert(ProviderType::Application);
|
||||
}
|
||||
set
|
||||
};
|
||||
|
||||
let filter = Self {
|
||||
enabled,
|
||||
active_prefix: None,
|
||||
accept_all: false,
|
||||
};
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"[Filter] Created with enabled providers: {:?}",
|
||||
filter.enabled
|
||||
);
|
||||
|
||||
filter
|
||||
}
|
||||
|
||||
/// Default filter: apps only
|
||||
#[allow(dead_code)]
|
||||
pub fn apps_only() -> Self {
|
||||
Self {
|
||||
enabled: HashSet::from([ProviderType::Application]),
|
||||
active_prefix: None,
|
||||
accept_all: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle a provider on/off
|
||||
pub fn toggle(&mut self, provider: ProviderType) {
|
||||
if self.enabled.contains(&provider) {
|
||||
self.enabled.remove(&provider);
|
||||
// Ensure at least one provider is always enabled
|
||||
if self.enabled.is_empty() {
|
||||
self.enabled.insert(ProviderType::Application);
|
||||
}
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"[Filter] Toggled OFF {:?}, enabled: {:?}",
|
||||
provider, self.enabled
|
||||
);
|
||||
} else {
|
||||
#[cfg(feature = "dev-logging")]
|
||||
let provider_debug = format!("{:?}", provider);
|
||||
self.enabled.insert(provider);
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"[Filter] Toggled ON {}, enabled: {:?}",
|
||||
provider_debug, self.enabled
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable a specific provider
|
||||
pub fn enable(&mut self, provider: ProviderType) {
|
||||
self.enabled.insert(provider);
|
||||
}
|
||||
|
||||
/// Disable a specific provider (ensures at least one remains)
|
||||
pub fn disable(&mut self, provider: ProviderType) {
|
||||
self.enabled.remove(&provider);
|
||||
if self.enabled.is_empty() {
|
||||
self.enabled.insert(ProviderType::Application);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set to single provider mode
|
||||
pub fn set_single_mode(&mut self, provider: ProviderType) {
|
||||
self.enabled.clear();
|
||||
self.enabled.insert(provider);
|
||||
}
|
||||
|
||||
/// Set prefix mode (from :app, :cmd, etc.)
|
||||
pub fn set_prefix(&mut self, prefix: Option<ProviderType>) {
|
||||
#[cfg(feature = "dev-logging")]
|
||||
if self.active_prefix != prefix {
|
||||
debug!(
|
||||
"[Filter] Prefix changed: {:?} -> {:?}",
|
||||
self.active_prefix, prefix
|
||||
);
|
||||
}
|
||||
self.active_prefix = prefix;
|
||||
}
|
||||
|
||||
/// Check if a provider should be searched
|
||||
pub fn is_active(&self, provider: ProviderType) -> bool {
|
||||
if let Some(ref prefix) = self.active_prefix {
|
||||
&provider == prefix
|
||||
} else if self.accept_all {
|
||||
true
|
||||
} else {
|
||||
self.enabled.contains(&provider)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if provider is in enabled set (ignoring prefix)
|
||||
pub fn is_enabled(&self, provider: ProviderType) -> bool {
|
||||
self.accept_all || self.enabled.contains(&provider)
|
||||
}
|
||||
|
||||
/// Get current active prefix if any
|
||||
#[allow(dead_code)]
|
||||
pub fn active_prefix(&self) -> Option<ProviderType> {
|
||||
self.active_prefix.clone()
|
||||
}
|
||||
|
||||
/// Parse query for prefix syntax
|
||||
/// Prefixes map to Plugin(type_id) for plugin providers
|
||||
pub fn parse_query(query: &str) -> ParsedQuery {
|
||||
let trimmed = query.trim_start();
|
||||
|
||||
// Check for tag filter pattern: ":tag:XXX query" or ":tag:XXX"
|
||||
if let Some(rest) = trimmed.strip_prefix(":tag:") {
|
||||
// Find the end of the tag (space or end of string)
|
||||
if let Some(space_idx) = rest.find(' ') {
|
||||
let tag = rest[..space_idx].to_lowercase();
|
||||
let query_part = rest[space_idx + 1..].to_string();
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"[Filter] parse_query({:?}) -> tag={:?}, query={:?}",
|
||||
query, tag, query_part
|
||||
);
|
||||
return ParsedQuery {
|
||||
prefix: None,
|
||||
tag_filter: Some(tag),
|
||||
query: query_part,
|
||||
};
|
||||
} else {
|
||||
// Just the tag, no query yet
|
||||
let tag = rest.to_lowercase();
|
||||
return ParsedQuery {
|
||||
prefix: None,
|
||||
tag_filter: Some(tag),
|
||||
query: String::new(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Core provider prefixes
|
||||
let core_prefixes: &[(&str, ProviderType)] = &[
|
||||
(":app ", ProviderType::Application),
|
||||
(":apps ", ProviderType::Application),
|
||||
(":cmd ", ProviderType::Command),
|
||||
(":command ", ProviderType::Command),
|
||||
];
|
||||
|
||||
// Plugin provider prefixes - mapped to Plugin(type_id)
|
||||
let plugin_prefixes: &[(&str, &str)] = &[
|
||||
(":bm ", "bookmarks"),
|
||||
(":bookmark ", "bookmarks"),
|
||||
(":bookmarks ", "bookmarks"),
|
||||
(":calc ", "calc"),
|
||||
(":calculator ", "calc"),
|
||||
(":clip ", "clipboard"),
|
||||
(":clipboard ", "clipboard"),
|
||||
(":emoji ", "emoji"),
|
||||
(":emojis ", "emoji"),
|
||||
(":file ", "filesearch"),
|
||||
(":files ", "filesearch"),
|
||||
(":find ", "filesearch"),
|
||||
(":script ", "scripts"),
|
||||
(":scripts ", "scripts"),
|
||||
(":ssh ", "ssh"),
|
||||
(":sys ", "system"),
|
||||
(":system ", "system"),
|
||||
(":power ", "system"),
|
||||
(":uuctl ", "uuctl"),
|
||||
(":systemd ", "uuctl"),
|
||||
(":web ", "websearch"),
|
||||
(":search ", "websearch"),
|
||||
];
|
||||
|
||||
// Check core prefixes
|
||||
for (prefix_str, provider) in core_prefixes {
|
||||
if let Some(rest) = trimmed.strip_prefix(prefix_str) {
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"[Filter] parse_query({:?}) -> prefix={:?}, query={:?}",
|
||||
query, provider, rest
|
||||
);
|
||||
return ParsedQuery {
|
||||
prefix: Some(provider.clone()),
|
||||
tag_filter: None,
|
||||
query: rest.to_string(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check plugin prefixes
|
||||
for (prefix_str, type_id) in plugin_prefixes {
|
||||
if let Some(rest) = trimmed.strip_prefix(prefix_str) {
|
||||
let provider = ProviderType::Plugin(type_id.to_string());
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"[Filter] parse_query({:?}) -> prefix={:?}, query={:?}",
|
||||
query, provider, rest
|
||||
);
|
||||
return ParsedQuery {
|
||||
prefix: Some(provider),
|
||||
tag_filter: None,
|
||||
query: rest.to_string(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Handle partial prefixes (still typing)
|
||||
let partial_core: &[(&str, ProviderType)] = &[
|
||||
(":app", ProviderType::Application),
|
||||
(":apps", ProviderType::Application),
|
||||
(":cmd", ProviderType::Command),
|
||||
(":command", ProviderType::Command),
|
||||
];
|
||||
|
||||
let partial_plugin: &[(&str, &str)] = &[
|
||||
(":bm", "bookmarks"),
|
||||
(":bookmark", "bookmarks"),
|
||||
(":bookmarks", "bookmarks"),
|
||||
(":calc", "calc"),
|
||||
(":calculator", "calc"),
|
||||
(":clip", "clipboard"),
|
||||
(":clipboard", "clipboard"),
|
||||
(":emoji", "emoji"),
|
||||
(":emojis", "emoji"),
|
||||
(":file", "filesearch"),
|
||||
(":files", "filesearch"),
|
||||
(":find", "filesearch"),
|
||||
(":script", "scripts"),
|
||||
(":scripts", "scripts"),
|
||||
(":ssh", "ssh"),
|
||||
(":sys", "system"),
|
||||
(":system", "system"),
|
||||
(":power", "system"),
|
||||
(":uuctl", "uuctl"),
|
||||
(":systemd", "uuctl"),
|
||||
(":web", "websearch"),
|
||||
(":search", "websearch"),
|
||||
];
|
||||
|
||||
for (prefix_str, provider) in partial_core {
|
||||
if trimmed == *prefix_str {
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"[Filter] parse_query({:?}) -> partial prefix {:?}",
|
||||
query, provider
|
||||
);
|
||||
return ParsedQuery {
|
||||
prefix: Some(provider.clone()),
|
||||
tag_filter: None,
|
||||
query: String::new(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (prefix_str, type_id) in partial_plugin {
|
||||
if trimmed == *prefix_str {
|
||||
let provider = ProviderType::Plugin(type_id.to_string());
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"[Filter] parse_query({:?}) -> partial prefix {:?}",
|
||||
query, provider
|
||||
);
|
||||
return ParsedQuery {
|
||||
prefix: Some(provider),
|
||||
tag_filter: None,
|
||||
query: String::new(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let result = ParsedQuery {
|
||||
prefix: None,
|
||||
tag_filter: None,
|
||||
query: query.to_string(),
|
||||
};
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"[Filter] parse_query({:?}) -> prefix={:?}, tag={:?}, query={:?}",
|
||||
query, result.prefix, result.tag_filter, result.query
|
||||
);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Get enabled providers for UI display (sorted)
|
||||
pub fn enabled_providers(&self) -> Vec<ProviderType> {
|
||||
let mut providers: Vec<_> = self.enabled.iter().cloned().collect();
|
||||
providers.sort_by_key(|p| match p {
|
||||
ProviderType::Application => 0,
|
||||
ProviderType::Command => 1,
|
||||
ProviderType::Dmenu => 2,
|
||||
ProviderType::Plugin(_) => 100, // Plugin providers sort after core
|
||||
});
|
||||
providers
|
||||
}
|
||||
|
||||
/// Create a filter from a list of mode name strings.
|
||||
///
|
||||
/// Maps each string to a ProviderType: "app" -> Application, "cmd" -> Command,
|
||||
/// "dmenu" -> Dmenu, anything else -> Plugin(id). An empty list produces an
|
||||
/// all-providers filter.
|
||||
pub fn from_mode_strings(modes: &[String]) -> Self {
|
||||
if modes.is_empty() {
|
||||
return Self::all();
|
||||
}
|
||||
let enabled: HashSet<ProviderType> = modes
|
||||
.iter()
|
||||
.map(|s| Self::mode_string_to_provider_type(s))
|
||||
.collect();
|
||||
Self {
|
||||
enabled,
|
||||
active_prefix: None,
|
||||
accept_all: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a filter that accepts all providers, including any
|
||||
/// dynamically loaded plugin.
|
||||
///
|
||||
/// Sets `accept_all` so that `is_active`/`is_enabled` return true for
|
||||
/// every `ProviderType` without maintaining a static list of plugin IDs.
|
||||
/// Core types are still placed in `enabled` for UI purposes (tab display).
|
||||
///
|
||||
/// The daemon uses this as the default when no modes are specified.
|
||||
pub fn all() -> Self {
|
||||
let mut enabled = HashSet::new();
|
||||
enabled.insert(ProviderType::Application);
|
||||
enabled.insert(ProviderType::Command);
|
||||
enabled.insert(ProviderType::Dmenu);
|
||||
Self {
|
||||
enabled,
|
||||
active_prefix: None,
|
||||
accept_all: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a mode string to a ProviderType.
|
||||
///
|
||||
/// Delegates to the existing `FromStr` impl on `ProviderType` which maps
|
||||
/// "app"/"apps"/"application" -> Application, "cmd"/"command" -> Command,
|
||||
/// "dmenu" -> Dmenu, and everything else -> Plugin(id).
|
||||
pub fn mode_string_to_provider_type(mode: &str) -> ProviderType {
|
||||
mode.parse::<ProviderType>()
|
||||
.unwrap_or_else(|_| ProviderType::Plugin(mode.to_string()))
|
||||
}
|
||||
|
||||
/// Get display name for current mode
|
||||
pub fn mode_display_name(&self) -> &'static str {
|
||||
if let Some(ref prefix) = self.active_prefix {
|
||||
return match prefix {
|
||||
ProviderType::Application => "Apps",
|
||||
ProviderType::Command => "Commands",
|
||||
ProviderType::Dmenu => "dmenu",
|
||||
ProviderType::Plugin(_) => "Plugin",
|
||||
};
|
||||
}
|
||||
|
||||
let enabled: Vec<_> = self.enabled_providers();
|
||||
if enabled.len() == 1 {
|
||||
match &enabled[0] {
|
||||
ProviderType::Application => "Apps",
|
||||
ProviderType::Command => "Commands",
|
||||
ProviderType::Dmenu => "dmenu",
|
||||
ProviderType::Plugin(_) => "Plugin",
|
||||
}
|
||||
} else {
|
||||
"All"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_query_with_prefix() {
|
||||
let result = ProviderFilter::parse_query(":app firefox");
|
||||
assert_eq!(result.prefix, Some(ProviderType::Application));
|
||||
assert_eq!(result.query, "firefox");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_query_without_prefix() {
|
||||
let result = ProviderFilter::parse_query("firefox");
|
||||
assert_eq!(result.prefix, None);
|
||||
assert_eq!(result.query, "firefox");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_query_partial_prefix() {
|
||||
let result = ProviderFilter::parse_query(":cmd");
|
||||
assert_eq!(result.prefix, Some(ProviderType::Command));
|
||||
assert_eq!(result.query, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_query_plugin_prefix() {
|
||||
let result = ProviderFilter::parse_query(":calc 5+3");
|
||||
assert_eq!(
|
||||
result.prefix,
|
||||
Some(ProviderType::Plugin("calc".to_string()))
|
||||
);
|
||||
assert_eq!(result.query, "5+3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toggle_ensures_one_enabled() {
|
||||
let mut filter = ProviderFilter::apps_only();
|
||||
filter.toggle(ProviderType::Application);
|
||||
// Should still have apps enabled as fallback
|
||||
assert!(filter.is_enabled(ProviderType::Application));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_mode_strings_single_core() {
|
||||
let filter = ProviderFilter::from_mode_strings(&["app".to_string()]);
|
||||
assert!(filter.is_enabled(ProviderType::Application));
|
||||
assert!(!filter.is_enabled(ProviderType::Command));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_mode_strings_multiple() {
|
||||
let filter = ProviderFilter::from_mode_strings(&[
|
||||
"app".to_string(),
|
||||
"cmd".to_string(),
|
||||
"calc".to_string(),
|
||||
]);
|
||||
assert!(filter.is_enabled(ProviderType::Application));
|
||||
assert!(filter.is_enabled(ProviderType::Command));
|
||||
assert!(filter.is_enabled(ProviderType::Plugin("calc".to_string())));
|
||||
assert!(!filter.is_enabled(ProviderType::Dmenu));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_mode_strings_empty_returns_all() {
|
||||
let filter = ProviderFilter::from_mode_strings(&[]);
|
||||
assert!(filter.is_enabled(ProviderType::Application));
|
||||
assert!(filter.is_enabled(ProviderType::Command));
|
||||
assert!(filter.is_enabled(ProviderType::Dmenu));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_mode_strings_plugin() {
|
||||
let filter = ProviderFilter::from_mode_strings(&["emoji".to_string()]);
|
||||
assert!(filter.is_enabled(ProviderType::Plugin("emoji".to_string())));
|
||||
assert!(!filter.is_enabled(ProviderType::Application));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_mode_strings_dmenu() {
|
||||
let filter = ProviderFilter::from_mode_strings(&["dmenu".to_string()]);
|
||||
assert!(filter.is_enabled(ProviderType::Dmenu));
|
||||
assert!(!filter.is_enabled(ProviderType::Application));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_includes_core_types() {
|
||||
let filter = ProviderFilter::all();
|
||||
assert!(filter.is_enabled(ProviderType::Application));
|
||||
assert!(filter.is_enabled(ProviderType::Command));
|
||||
assert!(filter.is_enabled(ProviderType::Dmenu));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_accepts_any_plugin() {
|
||||
let filter = ProviderFilter::all();
|
||||
// Known plugins
|
||||
assert!(filter.is_enabled(ProviderType::Plugin("calc".to_string())));
|
||||
assert!(filter.is_enabled(ProviderType::Plugin("clipboard".to_string())));
|
||||
// Arbitrary unknown plugins must also be accepted
|
||||
assert!(filter.is_enabled(ProviderType::Plugin("some-future-plugin".to_string())));
|
||||
assert!(filter.is_enabled(ProviderType::Plugin("custom-user-plugin".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_is_active_for_any_plugin() {
|
||||
let filter = ProviderFilter::all();
|
||||
assert!(filter.is_active(ProviderType::Application));
|
||||
assert!(filter.is_active(ProviderType::Plugin("unknown-plugin".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_with_prefix_narrows_scope() {
|
||||
let mut filter = ProviderFilter::all();
|
||||
filter.set_prefix(Some(ProviderType::Application));
|
||||
// Prefix narrows: only Application passes
|
||||
assert!(filter.is_active(ProviderType::Application));
|
||||
assert!(!filter.is_active(ProviderType::Command));
|
||||
assert!(!filter.is_active(ProviderType::Plugin("calc".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_explicit_mode_filter_rejects_unknown_plugins() {
|
||||
let filter = ProviderFilter::from_mode_strings(&["app".to_string(), "cmd".to_string()]);
|
||||
assert!(filter.is_active(ProviderType::Application));
|
||||
assert!(filter.is_active(ProviderType::Command));
|
||||
// Plugins not in the explicit list must be rejected
|
||||
assert!(!filter.is_active(ProviderType::Plugin("calc".to_string())));
|
||||
assert!(!filter.is_active(ProviderType::Plugin("unknown".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mode_string_to_provider_type_core() {
|
||||
assert_eq!(
|
||||
ProviderFilter::mode_string_to_provider_type("app"),
|
||||
ProviderType::Application
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderFilter::mode_string_to_provider_type("cmd"),
|
||||
ProviderType::Command
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderFilter::mode_string_to_provider_type("dmenu"),
|
||||
ProviderType::Dmenu
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mode_string_to_provider_type_plugin() {
|
||||
assert_eq!(
|
||||
ProviderFilter::mode_string_to_provider_type("calc"),
|
||||
ProviderType::Plugin("calc".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderFilter::mode_string_to_provider_type("websearch"),
|
||||
ProviderType::Plugin("websearch".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mode_string_to_provider_type_aliases() {
|
||||
assert_eq!(
|
||||
ProviderFilter::mode_string_to_provider_type("apps"),
|
||||
ProviderType::Application
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderFilter::mode_string_to_provider_type("application"),
|
||||
ProviderType::Application
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderFilter::mode_string_to_provider_type("command"),
|
||||
ProviderType::Command
|
||||
);
|
||||
}
|
||||
}
|
||||
63
crates/owlry-core/src/ipc.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Request {
|
||||
Query {
|
||||
text: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
modes: Option<Vec<String>>,
|
||||
},
|
||||
Launch {
|
||||
item_id: String,
|
||||
provider: String,
|
||||
},
|
||||
Providers,
|
||||
Refresh {
|
||||
provider: String,
|
||||
},
|
||||
Toggle,
|
||||
Submenu {
|
||||
plugin_id: String,
|
||||
data: String,
|
||||
},
|
||||
PluginAction {
|
||||
command: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Response {
|
||||
Results { items: Vec<ResultItem> },
|
||||
Providers { list: Vec<ProviderDesc> },
|
||||
SubmenuItems { items: Vec<ResultItem> },
|
||||
Ack,
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ResultItem {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub icon: String,
|
||||
pub provider: String,
|
||||
pub score: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub command: Option<String>,
|
||||
#[serde(default)]
|
||||
pub terminal: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ProviderDesc {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prefix: Option<String>,
|
||||
pub icon: String,
|
||||
pub position: String,
|
||||
}
|
||||
9
crates/owlry-core/src/lib.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
pub mod config;
|
||||
pub mod data;
|
||||
pub mod filter;
|
||||
pub mod ipc;
|
||||
pub mod notify;
|
||||
pub mod paths;
|
||||
pub mod plugins;
|
||||
pub mod providers;
|
||||
pub mod server;
|
||||
38
crates/owlry-core/src/main.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use log::info;
|
||||
|
||||
use owlry_core::paths;
|
||||
use owlry_core::server::Server;
|
||||
|
||||
fn main() {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
|
||||
|
||||
let sock = paths::socket_path();
|
||||
info!("Starting owlry-core daemon...");
|
||||
|
||||
// Ensure the socket parent directory exists
|
||||
if let Err(e) = paths::ensure_parent_dir(&sock) {
|
||||
eprintln!("Failed to create socket directory: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let server = match Server::bind(&sock) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to start owlry-core: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Graceful shutdown on SIGTERM/SIGINT
|
||||
let sock_cleanup = sock.clone();
|
||||
ctrlc::set_handler(move || {
|
||||
let _ = std::fs::remove_file(&sock_cleanup);
|
||||
std::process::exit(0);
|
||||
})
|
||||
.ok();
|
||||
|
||||
if let Err(e) = server.run() {
|
||||
eprintln!("Server error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,6 @@ pub fn cache_home() -> Option<PathBuf> {
|
||||
dirs::cache_dir()
|
||||
}
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// Owlry-specific directories
|
||||
// =============================================================================
|
||||
@@ -99,27 +98,75 @@ pub fn frecency_file() -> Option<PathBuf> {
|
||||
// =============================================================================
|
||||
|
||||
/// System data directories for applications (XDG_DATA_DIRS)
|
||||
///
|
||||
/// Follows the XDG Base Directory Specification:
|
||||
/// - $XDG_DATA_HOME/applications (defaults to ~/.local/share/applications)
|
||||
/// - $XDG_DATA_DIRS/*/applications (defaults to /usr/local/share:/usr/share)
|
||||
/// - Additional Flatpak and Snap directories
|
||||
pub fn system_data_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
|
||||
// User data directory first
|
||||
// Helper to add unique directories
|
||||
let mut add_dir = |path: PathBuf| {
|
||||
if seen.insert(path.clone()) {
|
||||
dirs.push(path);
|
||||
}
|
||||
};
|
||||
|
||||
// 1. User data directory first (highest priority)
|
||||
if let Some(data) = data_home() {
|
||||
dirs.push(data.join("applications"));
|
||||
add_dir(data.join("applications"));
|
||||
}
|
||||
|
||||
// System directories
|
||||
dirs.push(PathBuf::from("/usr/share/applications"));
|
||||
dirs.push(PathBuf::from("/usr/local/share/applications"));
|
||||
// 2. XDG_DATA_DIRS - parse the environment variable
|
||||
// Default per spec: /usr/local/share:/usr/share
|
||||
let xdg_data_dirs = std::env::var("XDG_DATA_DIRS")
|
||||
.unwrap_or_else(|_| "/usr/local/share:/usr/share".to_string());
|
||||
|
||||
// Flatpak directories
|
||||
if let Some(data) = data_home() {
|
||||
dirs.push(data.join("flatpak/exports/share/applications"));
|
||||
for dir in xdg_data_dirs.split(':') {
|
||||
if !dir.is_empty() {
|
||||
add_dir(PathBuf::from(dir).join("applications"));
|
||||
}
|
||||
}
|
||||
dirs.push(PathBuf::from("/var/lib/flatpak/exports/share/applications"));
|
||||
|
||||
// 3. Always include standard system directories as fallback
|
||||
// Some environments set XDG_DATA_DIRS without including these
|
||||
add_dir(PathBuf::from("/usr/share/applications"));
|
||||
add_dir(PathBuf::from("/usr/local/share/applications"));
|
||||
|
||||
// 4. Flatpak directories (user and system)
|
||||
if let Some(data) = data_home() {
|
||||
add_dir(data.join("flatpak/exports/share/applications"));
|
||||
}
|
||||
add_dir(PathBuf::from("/var/lib/flatpak/exports/share/applications"));
|
||||
|
||||
// 5. Snap directories
|
||||
add_dir(PathBuf::from("/var/lib/snapd/desktop/applications"));
|
||||
|
||||
// 6. Nix directories (common on NixOS)
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
add_dir(home.join(".nix-profile/share/applications"));
|
||||
}
|
||||
add_dir(PathBuf::from("/run/current-system/sw/share/applications"));
|
||||
|
||||
dirs
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Runtime files
|
||||
// =============================================================================
|
||||
|
||||
/// IPC socket path: `$XDG_RUNTIME_DIR/owlry/owlry.sock`
|
||||
///
|
||||
/// Falls back to `/tmp` if `$XDG_RUNTIME_DIR` is not set.
|
||||
pub fn socket_path() -> PathBuf {
|
||||
let runtime_dir = std::env::var("XDG_RUNTIME_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from("/tmp"));
|
||||
runtime_dir.join(APP_NAME).join("owlry.sock")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helper functions
|
||||
// =============================================================================
|
||||
@@ -127,9 +174,10 @@ pub fn system_data_dirs() -> Vec<PathBuf> {
|
||||
/// Ensure parent directory of a file exists
|
||||
pub fn ensure_parent_dir(path: &std::path::Path) -> std::io::Result<()> {
|
||||
if let Some(parent) = path.parent()
|
||||
&& !parent.exists() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
&& !parent.exists()
|
||||
{
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -54,9 +54,9 @@ pub fn register_action_api(lua: &Lua, owlry: &Table, plugin_id: &str) -> LuaResu
|
||||
.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"))?;
|
||||
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();
|
||||
@@ -166,7 +166,7 @@ pub fn get_actions_for_item(lua: &Lua, item: &Table) -> LuaResult<Vec<ActionRegi
|
||||
// Check filter if present
|
||||
if let Ok(filter) = entry.get::<Function>("filter") {
|
||||
match filter.call::<bool>(item.clone()) {
|
||||
Ok(true) => {} // Include this action
|
||||
Ok(true) => {} // Include this action
|
||||
Ok(false) => continue, // Skip this action
|
||||
Err(e) => {
|
||||
log::warn!("Action filter failed: {}", e);
|
||||
@@ -220,7 +220,8 @@ mod tests {
|
||||
fn test_action_registration() {
|
||||
let lua = setup_lua("test-plugin");
|
||||
|
||||
let chunk = lua.load(r#"
|
||||
let chunk = lua.load(
|
||||
r#"
|
||||
return owlry.action.register({
|
||||
id = "copy-name",
|
||||
name = "Copy Name",
|
||||
@@ -229,7 +230,8 @@ mod tests {
|
||||
-- copy logic here
|
||||
end
|
||||
})
|
||||
"#);
|
||||
"#,
|
||||
);
|
||||
let action_id: String = chunk.call(()).unwrap();
|
||||
assert_eq!(action_id, "test-plugin:copy-name");
|
||||
|
||||
@@ -243,7 +245,8 @@ mod tests {
|
||||
fn test_action_with_filter() {
|
||||
let lua = setup_lua("test-plugin");
|
||||
|
||||
let chunk = lua.load(r#"
|
||||
let chunk = lua.load(
|
||||
r#"
|
||||
owlry.action.register({
|
||||
id = "bookmark-action",
|
||||
name = "Open in Browser",
|
||||
@@ -252,7 +255,8 @@ mod tests {
|
||||
end,
|
||||
handler = function(item) end
|
||||
})
|
||||
"#);
|
||||
"#,
|
||||
);
|
||||
chunk.call::<()>(()).unwrap();
|
||||
|
||||
// Create bookmark item
|
||||
@@ -276,14 +280,16 @@ mod tests {
|
||||
fn test_action_unregister() {
|
||||
let lua = setup_lua("test-plugin");
|
||||
|
||||
let chunk = lua.load(r#"
|
||||
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);
|
||||
|
||||
@@ -296,7 +302,8 @@ mod tests {
|
||||
let lua = setup_lua("test-plugin");
|
||||
|
||||
// Register action that sets a global
|
||||
let chunk = lua.load(r#"
|
||||
let chunk = lua.load(
|
||||
r#"
|
||||
result = nil
|
||||
owlry.action.register({
|
||||
id = "test-exec",
|
||||
@@ -305,7 +312,8 @@ mod tests {
|
||||
result = item.name
|
||||
end
|
||||
})
|
||||
"#);
|
||||
"#,
|
||||
);
|
||||
chunk.call::<()>(()).unwrap();
|
||||
|
||||
// Create test item
|
||||
@@ -35,9 +35,9 @@ pub fn register_cache_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
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))
|
||||
})?;
|
||||
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() {
|
||||
@@ -50,8 +50,10 @@ pub fn register_cache_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
}
|
||||
|
||||
// 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)))?;
|
||||
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 {
|
||||
@@ -75,9 +77,9 @@ pub fn register_cache_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
expires_at,
|
||||
};
|
||||
|
||||
let mut cache = CACHE.lock().map_err(|e| {
|
||||
mlua::Error::external(format!("Failed to lock cache: {}", e))
|
||||
})?;
|
||||
let mut cache = CACHE
|
||||
.lock()
|
||||
.map_err(|e| mlua::Error::external(format!("Failed to lock cache: {}", e)))?;
|
||||
|
||||
cache.insert(key, entry);
|
||||
Ok(true)
|
||||
@@ -88,9 +90,9 @@ pub fn register_cache_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
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))
|
||||
})?;
|
||||
let mut cache = CACHE
|
||||
.lock()
|
||||
.map_err(|e| mlua::Error::external(format!("Failed to lock cache: {}", e)))?;
|
||||
|
||||
Ok(cache.remove(&key).is_some())
|
||||
})?,
|
||||
@@ -100,9 +102,9 @@ pub fn register_cache_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
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 mut cache = CACHE
|
||||
.lock()
|
||||
.map_err(|e| mlua::Error::external(format!("Failed to lock cache: {}", e)))?;
|
||||
|
||||
let count = cache.len();
|
||||
cache.clear();
|
||||
@@ -114,9 +116,9 @@ pub fn register_cache_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
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))
|
||||
})?;
|
||||
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())
|
||||
@@ -249,10 +251,12 @@ mod tests {
|
||||
let _: bool = chunk.call(()).unwrap();
|
||||
|
||||
// Get and verify
|
||||
let chunk = lua.load(r#"
|
||||
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);
|
||||
@@ -262,12 +266,14 @@ mod tests {
|
||||
fn test_cache_delete() {
|
||||
let lua = setup_lua();
|
||||
|
||||
let chunk = lua.load(r#"
|
||||
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());
|
||||
@@ -277,12 +283,14 @@ mod tests {
|
||||
fn test_cache_has() {
|
||||
let lua = setup_lua();
|
||||
|
||||
let chunk = lua.load(r#"
|
||||
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);
|
||||
@@ -329,13 +329,15 @@ mod tests {
|
||||
clear_all_hooks();
|
||||
let lua = setup_lua("test-plugin");
|
||||
|
||||
let chunk = lua.load(r#"
|
||||
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);
|
||||
|
||||
@@ -349,11 +351,13 @@ mod tests {
|
||||
clear_all_hooks();
|
||||
let lua = setup_lua("test-plugin");
|
||||
|
||||
let chunk = lua.load(r#"
|
||||
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
|
||||
@@ -367,11 +371,13 @@ mod tests {
|
||||
clear_all_hooks();
|
||||
let lua = setup_lua("test-plugin");
|
||||
|
||||
let chunk = lua.load(r#"
|
||||
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);
|
||||
@@ -383,14 +389,16 @@ mod tests {
|
||||
clear_all_hooks();
|
||||
let lua = setup_lua("test-plugin");
|
||||
|
||||
let chunk = lua.load(r#"
|
||||
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
|
||||
@@ -26,18 +26,21 @@ pub fn register_http_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
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)))?;
|
||||
.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 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()
|
||||
@@ -45,9 +48,9 @@ pub fn register_http_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
|
||||
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 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)?;
|
||||
@@ -78,18 +81,21 @@ pub fn register_http_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
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)))?;
|
||||
.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);
|
||||
}
|
||||
&& 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 {
|
||||
@@ -102,11 +108,7 @@ pub fn register_http_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
.body(json_str)
|
||||
}
|
||||
Value::Nil => request,
|
||||
_ => {
|
||||
return Err(mlua::Error::external(
|
||||
"POST body must be a string or table",
|
||||
))
|
||||
}
|
||||
_ => return Err(mlua::Error::external("POST body must be a string or table")),
|
||||
};
|
||||
|
||||
let response = request
|
||||
@@ -115,9 +117,9 @@ pub fn register_http_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
|
||||
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 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)?;
|
||||
@@ -149,19 +151,22 @@ pub fn register_http_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
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)))?;
|
||||
.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 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()
|
||||
@@ -174,9 +179,9 @@ pub fn register_http_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
)));
|
||||
}
|
||||
|
||||
let body = response
|
||||
.text()
|
||||
.map_err(|e| mlua::Error::external(format!("Failed to read response body: {}", e)))?;
|
||||
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)
|
||||
@@ -14,20 +14,20 @@ pub fn register_math_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
// 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())))
|
||||
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()))),
|
||||
}
|
||||
Err(e) => {
|
||||
Ok((None, Some(e.to_string())))
|
||||
}
|
||||
}
|
||||
})?,
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
|
||||
// owlry.math.calc(expression) -> number (throws on error)
|
||||
@@ -106,11 +106,13 @@ mod tests {
|
||||
fn test_calculate_basic() {
|
||||
let lua = setup_lua();
|
||||
|
||||
let chunk = lua.load(r#"
|
||||
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);
|
||||
}
|
||||
@@ -119,11 +121,13 @@ mod tests {
|
||||
fn test_calculate_complex() {
|
||||
let lua = setup_lua();
|
||||
|
||||
let chunk = lua.load(r#"
|
||||
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
|
||||
}
|
||||
@@ -132,14 +136,16 @@ mod tests {
|
||||
fn test_calculate_error() {
|
||||
let lua = setup_lua();
|
||||
|
||||
let chunk = lua.load(r#"
|
||||
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);
|
||||
}
|
||||
@@ -27,8 +27,14 @@ pub fn register_process_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
.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(
|
||||
"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())?;
|
||||
|
||||
@@ -95,9 +101,7 @@ pub fn register_env_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
// owlry.env.get(name) -> string or nil
|
||||
env_table.set(
|
||||
"get",
|
||||
lua.create_function(|_lua, name: String| {
|
||||
Ok(std::env::var(&name).ok())
|
||||
})?,
|
||||
lua.create_function(|_lua, name: String| Ok(std::env::var(&name).ok()))?,
|
||||
)?;
|
||||
|
||||
// owlry.env.get_or(name, default) -> string
|
||||
@@ -166,7 +170,8 @@ mod tests {
|
||||
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 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);
|
||||
}
|
||||
@@ -190,7 +195,8 @@ mod tests {
|
||||
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 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");
|
||||
}
|
||||
@@ -21,7 +21,12 @@ pub struct ThemeRegistration {
|
||||
}
|
||||
|
||||
/// Register theme APIs
|
||||
pub fn register_theme_api(lua: &Lua, owlry: &Table, plugin_id: &str, plugin_dir: &Path) -> LuaResult<()> {
|
||||
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();
|
||||
@@ -50,9 +55,7 @@ pub fn register_theme_api(lua: &Lua, owlry: &Table, plugin_id: &str, plugin_dir:
|
||||
.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());
|
||||
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") {
|
||||
@@ -197,13 +200,15 @@ mod tests {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let lua = setup_lua("test-plugin", temp.path());
|
||||
|
||||
let chunk = lua.load(r#"
|
||||
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");
|
||||
|
||||
@@ -221,12 +226,14 @@ mod tests {
|
||||
|
||||
let lua = setup_lua("test-plugin", temp.path());
|
||||
|
||||
let chunk = lua.load(r#"
|
||||
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");
|
||||
|
||||
@@ -240,11 +247,13 @@ mod tests {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let lua = setup_lua("test-plugin", temp.path());
|
||||
|
||||
let chunk = lua.load(r#"
|
||||
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();
|
||||
@@ -262,10 +271,12 @@ mod tests {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let lua = setup_lua("test-plugin", temp.path());
|
||||
|
||||
let chunk = lua.load(r#"
|
||||
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);
|
||||
|
||||
@@ -189,9 +189,10 @@ pub fn register_fs_api(lua: &Lua, owlry: &Table, plugin_dir: &Path) -> LuaResult
|
||||
// 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())?)));
|
||||
}
|
||||
&& 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)),
|
||||
@@ -295,7 +296,8 @@ pub fn register_fs_api(lua: &Lua, owlry: &Table, plugin_dir: &Path) -> LuaResult
|
||||
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()
|
||||
let is_exec = full_path
|
||||
.metadata()
|
||||
.map(|m| m.permissions().mode() & 0o111 != 0)
|
||||
.unwrap_or(false);
|
||||
Ok(is_exec)
|
||||
@@ -335,28 +337,24 @@ pub fn register_json_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
// 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)?))),
|
||||
}
|
||||
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)?))),
|
||||
}
|
||||
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)?))),
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -388,13 +386,16 @@ fn lua_to_json(value: &Value) -> Result<serde_json::Value, String> {
|
||||
.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()
|
||||
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)));
|
||||
&& (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)
|
||||
@@ -475,9 +476,13 @@ mod tests {
|
||||
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.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.warn('warning')")
|
||||
.call::<()>(())
|
||||
.unwrap();
|
||||
lua.load("owlry.log.error('error')").call::<()>(()).unwrap();
|
||||
}
|
||||
|
||||
@@ -485,10 +490,7 @@ mod tests {
|
||||
fn test_path_api() {
|
||||
let (lua, _temp) = create_test_lua();
|
||||
|
||||
let home: String = lua
|
||||
.load("return owlry.path.home()")
|
||||
.call(())
|
||||
.unwrap();
|
||||
let home: String = lua.load("return owlry.path.home()").call(()).unwrap();
|
||||
assert!(!home.is_empty());
|
||||
|
||||
let joined: String = lua
|
||||
@@ -7,7 +7,7 @@ use mlua::Lua;
|
||||
use super::api;
|
||||
use super::error::{PluginError, PluginResult};
|
||||
use super::manifest::PluginManifest;
|
||||
use super::runtime::{create_lua_runtime, load_file, SandboxConfig};
|
||||
use super::runtime::{SandboxConfig, create_lua_runtime, load_file};
|
||||
|
||||
/// A loaded plugin instance
|
||||
#[derive(Debug)]
|
||||
@@ -94,7 +94,10 @@ impl LoadedPlugin {
|
||||
}
|
||||
|
||||
/// Call a provider's refresh function
|
||||
pub fn call_provider_refresh(&self, provider_name: &str) -> PluginResult<Vec<super::PluginItem>> {
|
||||
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(),
|
||||
@@ -108,7 +111,11 @@ impl LoadedPlugin {
|
||||
|
||||
/// 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>> {
|
||||
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(),
|
||||
@@ -138,8 +145,8 @@ impl LoadedPlugin {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use super::super::manifest::{check_compatibility, discover_plugins};
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
@@ -112,11 +112,16 @@ pub struct PluginPermissions {
|
||||
/// 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)>> {
|
||||
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());
|
||||
log::debug!(
|
||||
"Plugins directory does not exist: {}",
|
||||
plugins_dir.display()
|
||||
);
|
||||
return Ok(plugins);
|
||||
}
|
||||
|
||||
@@ -143,7 +148,11 @@ pub fn discover_plugins(plugins_dir: &Path) -> PluginResult<HashMap<String, (Plu
|
||||
log::warn!("Duplicate plugin ID '{}', skipping {}", id, path.display());
|
||||
continue;
|
||||
}
|
||||
log::info!("Discovered plugin: {} v{}", manifest.plugin.name, manifest.plugin.version);
|
||||
log::info!(
|
||||
"Discovered plugin: {} v{}",
|
||||
manifest.plugin.name,
|
||||
manifest.plugin.version
|
||||
);
|
||||
plugins.insert(id, (manifest, path));
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -204,7 +213,12 @@ impl PluginManifest {
|
||||
});
|
||||
}
|
||||
|
||||
if !self.plugin.id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
|
||||
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(),
|
||||
@@ -223,7 +237,10 @@ impl PluginManifest {
|
||||
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),
|
||||
message: format!(
|
||||
"Invalid owlry_version constraint: {}",
|
||||
self.plugin.owlry_version
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
//! ```
|
||||
|
||||
// Always available
|
||||
pub mod commands;
|
||||
pub mod error;
|
||||
pub mod manifest;
|
||||
pub mod native_loader;
|
||||
@@ -51,7 +50,7 @@ pub use loader::LoadedPlugin;
|
||||
|
||||
// Used by plugins/commands.rs for plugin CLI commands
|
||||
#[allow(unused_imports)]
|
||||
pub use manifest::{check_compatibility, discover_plugins, PluginManifest};
|
||||
pub use manifest::{PluginManifest, check_compatibility, discover_plugins};
|
||||
|
||||
// ============================================================================
|
||||
// Lua Plugin Manager (only available with lua feature)
|
||||
@@ -65,7 +64,7 @@ mod lua_manager {
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
|
||||
use manifest::{discover_plugins, check_compatibility};
|
||||
use manifest::{check_compatibility, discover_plugins};
|
||||
|
||||
/// Plugin manager coordinates loading, initialization, and lifecycle of Lua plugins
|
||||
pub struct PluginManager {
|
||||
@@ -159,7 +158,10 @@ mod lua_manager {
|
||||
|
||||
/// Get all enabled plugins
|
||||
pub fn enabled_plugins(&self) -> impl Iterator<Item = Rc<RefCell<LoadedPlugin>>> + '_ {
|
||||
self.plugins.values().filter(|p| p.borrow().enabled).cloned()
|
||||
self.plugins
|
||||
.values()
|
||||
.filter(|p| p.borrow().enabled)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Get the number of loaded plugins
|
||||
@@ -177,7 +179,10 @@ mod lua_manager {
|
||||
/// 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 plugin_rc = self
|
||||
.plugins
|
||||
.get(id)
|
||||
.ok_or_else(|| PluginError::NotFound(id.to_string()))?;
|
||||
let mut plugin = plugin_rc.borrow_mut();
|
||||
|
||||
if !plugin.enabled {
|
||||
@@ -192,7 +197,10 @@ mod lua_manager {
|
||||
/// 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()))?;
|
||||
let plugin_rc = self
|
||||
.plugins
|
||||
.get(id)
|
||||
.ok_or_else(|| PluginError::NotFound(id.to_string()))?;
|
||||
plugin_rc.borrow_mut().enabled = false;
|
||||
Ok(())
|
||||
}
|
||||
@@ -201,7 +209,13 @@ mod lua_manager {
|
||||
#[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()))
|
||||
.filter(|p| {
|
||||
p.borrow()
|
||||
.manifest
|
||||
.provides
|
||||
.providers
|
||||
.contains(&provider_name.to_string())
|
||||
})
|
||||
.map(|p| p.borrow().id().to_string())
|
||||
.collect()
|
||||
}
|
||||
@@ -209,13 +223,15 @@ mod lua_manager {
|
||||
/// 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)
|
||||
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)
|
||||
self.enabled_plugins()
|
||||
.any(|p| p.borrow().manifest.provides.hooks)
|
||||
}
|
||||
|
||||
/// Get all theme names provided by plugins
|
||||
@@ -17,8 +17,8 @@ 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,
|
||||
API_VERSION, HostAPI, NotifyUrgency, PluginInfo, PluginVTable, ProviderHandle, ProviderInfo,
|
||||
ProviderKind, RStr,
|
||||
};
|
||||
|
||||
use crate::notify;
|
||||
@@ -28,9 +28,18 @@ use crate::notify;
|
||||
// ============================================================================
|
||||
|
||||
/// Host notification handler
|
||||
extern "C" fn host_notify(summary: RStr<'_>, body: RStr<'_>, icon: RStr<'_>, urgency: NotifyUrgency) {
|
||||
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 icon_opt = if icon_str.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(icon_str)
|
||||
};
|
||||
|
||||
let notify_urgency = match urgency {
|
||||
NotifyUrgency::Low => notify::NotifyUrgency::Low,
|
||||
@@ -121,7 +130,9 @@ impl NativePlugin {
|
||||
handle: ProviderHandle,
|
||||
query: &str,
|
||||
) -> Vec<owlry_plugin_api::PluginItem> {
|
||||
(self.vtable.provider_query)(handle, query.into()).into_iter().collect()
|
||||
(self.vtable.provider_query)(handle, query.into())
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Drop a provider handle
|
||||
@@ -110,9 +110,10 @@ impl RegistryClient {
|
||||
|
||||
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;
|
||||
}
|
||||
&& let Ok(elapsed) = SystemTime::now().duration_since(modified)
|
||||
{
|
||||
return elapsed < CACHE_DURATION;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
@@ -120,11 +121,13 @@ impl RegistryClient {
|
||||
/// 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()
|
||||
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);
|
||||
}
|
||||
&& let Ok(index) = toml::from_str(&content)
|
||||
{
|
||||
return Ok(index);
|
||||
}
|
||||
|
||||
// Fetch from network
|
||||
self.fetch_from_network()
|
||||
@@ -134,12 +137,7 @@ impl RegistryClient {
|
||||
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,
|
||||
])
|
||||
.args(["-fsSL", "--max-time", "30", &self.registry_url])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run curl: {}", e))?;
|
||||
|
||||
@@ -185,7 +183,9 @@ impl RegistryClient {
|
||||
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))
|
||||
|| p.tags
|
||||
.iter()
|
||||
.any(|t| t.to_lowercase().contains(&query_lower))
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -210,8 +210,7 @@ impl RegistryClient {
|
||||
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))?;
|
||||
fs::remove_file(&cache_path).map_err(|e| format!("Failed to remove cache: {}", e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -26,7 +26,7 @@ impl Default for SandboxConfig {
|
||||
allow_commands: false,
|
||||
allow_network: false,
|
||||
allow_external_fs: false,
|
||||
max_run_time_ms: 5000, // 5 seconds
|
||||
max_run_time_ms: 5000, // 5 seconds
|
||||
max_memory: 64 * 1024 * 1024, // 64 MB
|
||||
}
|
||||
}
|
||||
@@ -49,11 +49,7 @@ 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 libs = StdLib::COROUTINE | StdLib::TABLE | StdLib::STRING | StdLib::UTF8 | StdLib::MATH;
|
||||
|
||||
let lua = Lua::new_with(libs, mlua::LuaOptions::default())?;
|
||||
|
||||
@@ -75,9 +71,15 @@ fn setup_safe_globals(lua: &Lua) -> LuaResult<()> {
|
||||
// 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(
|
||||
"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(
|
||||
"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)?;
|
||||
|
||||
@@ -107,8 +109,7 @@ fn os_time(_lua: &Lua, _args: ()) -> LuaResult<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)?;
|
||||
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()?
|
||||
@@ -59,7 +59,11 @@ pub struct ScriptRuntimeVTable {
|
||||
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 query: extern "C" fn(
|
||||
handle: RuntimeHandle,
|
||||
provider_id: RStr<'_>,
|
||||
query: RStr<'_>,
|
||||
) -> RVec<PluginItem>,
|
||||
pub drop: extern "C" fn(handle: RuntimeHandle),
|
||||
}
|
||||
|
||||
@@ -100,9 +104,8 @@ impl LoadedRuntime {
|
||||
}
|
||||
|
||||
// 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 = unsafe { Library::new(library_path) }
|
||||
.map_err(|e| PluginError::LoadError(format!("{}: {}", library_path.display(), e)))?;
|
||||
|
||||
let library = Arc::new(library);
|
||||
|
||||
@@ -152,12 +155,8 @@ impl LoadedRuntime {
|
||||
self.providers
|
||||
.iter()
|
||||
.map(|info| {
|
||||
let provider = RuntimeProvider::new(
|
||||
self.name,
|
||||
self.vtable,
|
||||
self.handle,
|
||||
info.clone(),
|
||||
);
|
||||
let provider =
|
||||
RuntimeProvider::new(self.name, self.vtable, self.handle, info.clone());
|
||||
Box::new(provider) as Box<dyn Provider>
|
||||
})
|
||||
.collect()
|
||||
@@ -227,7 +226,10 @@ impl Provider for RuntimeProvider {
|
||||
|
||||
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();
|
||||
self.items = items_rvec
|
||||
.into_iter()
|
||||
.map(|i| self.convert_item(i))
|
||||
.collect();
|
||||
|
||||
log::debug!(
|
||||
"[RuntimeProvider] '{}' refreshed with {} items",
|
||||
@@ -246,12 +248,16 @@ 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()
|
||||
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()
|
||||
PathBuf::from(SYSTEM_RUNTIMES_DIR)
|
||||
.join("librune.so")
|
||||
.exists()
|
||||
}
|
||||
|
||||
impl LoadedRuntime {
|
||||
@@ -66,13 +66,14 @@ fn clean_desktop_exec_field(cmd: &str) -> String {
|
||||
cleaned
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ApplicationProvider {
|
||||
items: Vec<LaunchItem>,
|
||||
}
|
||||
|
||||
impl ApplicationProvider {
|
||||
pub fn new() -> Self {
|
||||
Self { items: Vec::new() }
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn get_application_dirs() -> Vec<std::path::PathBuf> {
|
||||
@@ -98,6 +99,15 @@ impl Provider for ApplicationProvider {
|
||||
// Empty locale list for default locale
|
||||
let locales: &[&str] = &[];
|
||||
|
||||
// Get current desktop environment(s) for OnlyShowIn/NotShowIn filtering
|
||||
// XDG_CURRENT_DESKTOP can be colon-separated (e.g., "ubuntu:GNOME")
|
||||
let current_desktops: Vec<String> = std::env::var("XDG_CURRENT_DESKTOP")
|
||||
.unwrap_or_default()
|
||||
.split(':')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
for path in Iter::new(dirs.into_iter()) {
|
||||
let content = match std::fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
@@ -125,6 +135,27 @@ impl Provider for ApplicationProvider {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply OnlyShowIn/NotShowIn filters only if we know the current desktop
|
||||
// If XDG_CURRENT_DESKTOP is not set, show all apps (don't filter)
|
||||
if !current_desktops.is_empty() {
|
||||
// OnlyShowIn: if set, current desktop must be in the list
|
||||
if desktop_entry.only_show_in().is_some_and(|only| {
|
||||
!current_desktops
|
||||
.iter()
|
||||
.any(|de| only.contains(&de.as_str()))
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// NotShowIn: if current desktop is in the list, skip
|
||||
if desktop_entry
|
||||
.not_show_in()
|
||||
.is_some_and(|not| current_desktops.iter().any(|de| not.contains(&de.as_str())))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let name = match desktop_entry.name(locales) {
|
||||
Some(n) => n.to_string(),
|
||||
None => continue,
|
||||
@@ -135,12 +166,17 @@ impl Provider for ApplicationProvider {
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Extract categories as tags (lowercase for consistency)
|
||||
let tags: Vec<String> = desktop_entry
|
||||
// Extract categories and keywords as tags (lowercase for consistency)
|
||||
let mut tags: Vec<String> = desktop_entry
|
||||
.categories()
|
||||
.map(|cats| cats.into_iter().map(|s| s.to_lowercase()).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Add keywords for searchability (e.g., Nautilus has Name=Files but Keywords contains "nautilus")
|
||||
if let Some(keywords) = desktop_entry.keywords(locales) {
|
||||
tags.extend(keywords.into_iter().map(|s| s.to_lowercase()));
|
||||
}
|
||||
|
||||
let item = LaunchItem {
|
||||
id: path.to_string_lossy().to_string(),
|
||||
name,
|
||||
@@ -157,8 +193,16 @@ impl Provider for ApplicationProvider {
|
||||
|
||||
debug!("Found {} applications", self.items.len());
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"XDG_CURRENT_DESKTOP={:?}, scanned dirs count={}",
|
||||
current_desktops,
|
||||
Self::get_application_dirs().len()
|
||||
);
|
||||
|
||||
// Sort alphabetically by name
|
||||
self.items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
self.items
|
||||
.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
}
|
||||
|
||||
fn items(&self) -> &[LaunchItem] {
|
||||
@@ -180,7 +224,10 @@ mod tests {
|
||||
#[test]
|
||||
fn test_clean_desktop_exec_multiple_placeholders() {
|
||||
assert_eq!(clean_desktop_exec_field("app %f %u %U"), "app");
|
||||
assert_eq!(clean_desktop_exec_field("app --flag %u --other"), "app --flag --other");
|
||||
assert_eq!(
|
||||
clean_desktop_exec_field("app --flag %u --other"),
|
||||
"app --flag --other"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -210,4 +257,18 @@ mod tests {
|
||||
"bash -c 'echo %u'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_desktop_exec_preserves_env() {
|
||||
// env VAR=value pattern should be preserved
|
||||
assert_eq!(
|
||||
clean_desktop_exec_field("env GDK_BACKEND=x11 UBUNTU_MENUPROXY=0 audacity %F"),
|
||||
"env GDK_BACKEND=x11 UBUNTU_MENUPROXY=0 audacity"
|
||||
);
|
||||
// Multiple env vars
|
||||
assert_eq!(
|
||||
clean_desktop_exec_field("env FOO=bar BAZ=qux myapp %u"),
|
||||
"env FOO=bar BAZ=qux myapp"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,14 @@ use std::collections::HashSet;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct CommandProvider {
|
||||
items: Vec<LaunchItem>,
|
||||
}
|
||||
|
||||
impl CommandProvider {
|
||||
pub fn new() -> Self {
|
||||
Self { items: Vec::new() }
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn get_path_dirs() -> Vec<PathBuf> {
|
||||
@@ -97,7 +98,8 @@ impl Provider for CommandProvider {
|
||||
debug!("Found {} commands in PATH", self.items.len());
|
||||
|
||||
// Sort alphabetically
|
||||
self.items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
self.items
|
||||
.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
}
|
||||
|
||||
fn items(&self) -> &[LaunchItem] {
|
||||
@@ -95,9 +95,7 @@ impl Provider for LuaProvider {
|
||||
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>> {
|
||||
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() {
|
||||
957
crates/owlry-core/src/providers/mod.rs
Normal file
@@ -0,0 +1,957 @@
|
||||
// Core providers (no plugin equivalents)
|
||||
mod application;
|
||||
mod command;
|
||||
|
||||
// Native plugin bridge
|
||||
pub mod native_provider;
|
||||
|
||||
// Lua plugin bridge (optional)
|
||||
#[cfg(feature = "lua")]
|
||||
pub mod lua_provider;
|
||||
|
||||
// Re-exports for core providers
|
||||
pub use application::ApplicationProvider;
|
||||
pub use command::CommandProvider;
|
||||
|
||||
// Re-export native provider for plugin loading
|
||||
pub use native_provider::NativeProvider;
|
||||
|
||||
use fuzzy_matcher::FuzzyMatcher;
|
||||
use fuzzy_matcher::skim::SkimMatcherV2;
|
||||
use log::info;
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
use log::debug;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::data::FrecencyStore;
|
||||
|
||||
/// Metadata descriptor for an available provider (used by IPC/daemon API)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderDescriptor {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub prefix: Option<String>,
|
||||
pub icon: String,
|
||||
pub position: String,
|
||||
}
|
||||
|
||||
/// Represents a single searchable/launchable item
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LaunchItem {
|
||||
#[allow(dead_code)]
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub provider: ProviderType,
|
||||
pub command: String,
|
||||
pub terminal: bool,
|
||||
/// Tags/categories for filtering (e.g., from .desktop Categories)
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// Provider type identifier for filtering and badge display
|
||||
///
|
||||
/// Core types are built-in providers. All native plugins use Plugin(type_id).
|
||||
/// This keeps the core app free of plugin-specific knowledge.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum ProviderType {
|
||||
/// Built-in: Desktop applications from XDG directories
|
||||
Application,
|
||||
/// Built-in: Shell commands from PATH
|
||||
Command,
|
||||
/// Built-in: Pipe-based input (dmenu compatibility)
|
||||
Dmenu,
|
||||
/// Plugin-defined provider type with its type_id (e.g., "calc", "weather", "emoji")
|
||||
Plugin(String),
|
||||
}
|
||||
|
||||
impl std::str::FromStr for ProviderType {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
// Core built-in providers
|
||||
"app" | "apps" | "application" | "applications" => Ok(ProviderType::Application),
|
||||
"cmd" | "command" | "commands" => Ok(ProviderType::Command),
|
||||
"dmenu" => Ok(ProviderType::Dmenu),
|
||||
// Everything else is a plugin
|
||||
other => Ok(ProviderType::Plugin(other.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ProviderType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ProviderType::Application => write!(f, "app"),
|
||||
ProviderType::Command => write!(f, "cmd"),
|
||||
ProviderType::Dmenu => write!(f, "dmenu"),
|
||||
ProviderType::Plugin(type_id) => write!(f, "{}", type_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait for all search providers
|
||||
pub trait Provider: Send {
|
||||
#[allow(dead_code)]
|
||||
fn name(&self) -> &str;
|
||||
fn provider_type(&self) -> ProviderType;
|
||||
fn refresh(&mut self);
|
||||
fn items(&self) -> &[LaunchItem];
|
||||
}
|
||||
|
||||
/// Manages all providers and handles searching
|
||||
pub struct ProviderManager {
|
||||
/// Core static providers (apps, commands, dmenu)
|
||||
providers: Vec<Box<dyn Provider>>,
|
||||
/// Static native plugin providers (need query() for submenu support)
|
||||
static_native_providers: Vec<NativeProvider>,
|
||||
/// Dynamic providers from native plugins (calculator, websearch, filesearch)
|
||||
/// These are queried per-keystroke, not cached
|
||||
dynamic_providers: Vec<NativeProvider>,
|
||||
/// Widget providers from native plugins (weather, media, pomodoro)
|
||||
/// These appear at the top of results
|
||||
widget_providers: Vec<NativeProvider>,
|
||||
/// Fuzzy matcher for search
|
||||
matcher: SkimMatcherV2,
|
||||
}
|
||||
|
||||
impl ProviderManager {
|
||||
/// Create a new ProviderManager with core providers and native plugins.
|
||||
///
|
||||
/// Core providers (e.g., ApplicationProvider, CommandProvider, DmenuProvider) are
|
||||
/// passed in by the caller. Native plugins are categorized based on their declared
|
||||
/// ProviderKind and ProviderPosition.
|
||||
pub fn new(
|
||||
core_providers: Vec<Box<dyn Provider>>,
|
||||
native_providers: Vec<NativeProvider>,
|
||||
) -> Self {
|
||||
let mut manager = Self {
|
||||
providers: core_providers,
|
||||
static_native_providers: Vec::new(),
|
||||
dynamic_providers: Vec::new(),
|
||||
widget_providers: Vec::new(),
|
||||
matcher: SkimMatcherV2::default(),
|
||||
};
|
||||
|
||||
// Categorize native plugins based on their declared ProviderKind and ProviderPosition
|
||||
for provider in native_providers {
|
||||
let type_id = provider.type_id();
|
||||
|
||||
if provider.is_dynamic() {
|
||||
info!(
|
||||
"Registered dynamic provider: {} ({})",
|
||||
provider.name(),
|
||||
type_id
|
||||
);
|
||||
manager.dynamic_providers.push(provider);
|
||||
} else if provider.is_widget() {
|
||||
info!(
|
||||
"Registered widget provider: {} ({})",
|
||||
provider.name(),
|
||||
type_id
|
||||
);
|
||||
manager.widget_providers.push(provider);
|
||||
} else {
|
||||
info!(
|
||||
"Registered static provider: {} ({})",
|
||||
provider.name(),
|
||||
type_id
|
||||
);
|
||||
manager.static_native_providers.push(provider);
|
||||
}
|
||||
}
|
||||
|
||||
// Initial refresh
|
||||
manager.refresh_all();
|
||||
|
||||
manager
|
||||
}
|
||||
|
||||
/// Create a self-contained ProviderManager from config.
|
||||
///
|
||||
/// Loads native plugins, creates core providers (Application + Command),
|
||||
/// categorizes everything, and performs initial refresh. Used by the daemon
|
||||
/// which doesn't have the UI-driven setup path from `app.rs`.
|
||||
pub fn new_with_config(config: &Config) -> Self {
|
||||
use crate::plugins::native_loader::NativePluginLoader;
|
||||
use std::sync::Arc;
|
||||
|
||||
// Create core providers
|
||||
let core_providers: Vec<Box<dyn Provider>> = vec![
|
||||
Box::new(ApplicationProvider::new()),
|
||||
Box::new(CommandProvider::new()),
|
||||
];
|
||||
|
||||
// Load native plugins
|
||||
let mut loader = NativePluginLoader::new();
|
||||
loader.set_disabled(config.plugins.disabled_plugins.clone());
|
||||
|
||||
let native_providers = match loader.discover() {
|
||||
Ok(count) => {
|
||||
if count == 0 {
|
||||
info!("No native plugins found");
|
||||
Vec::new()
|
||||
} else {
|
||||
info!("Discovered {} native plugin(s)", count);
|
||||
let plugins: Vec<Arc<crate::plugins::native_loader::NativePlugin>> =
|
||||
loader.into_plugins();
|
||||
let mut providers = Vec::new();
|
||||
for plugin in plugins {
|
||||
for provider_info in &plugin.providers {
|
||||
let provider =
|
||||
NativeProvider::new(Arc::clone(&plugin), provider_info.clone());
|
||||
info!(
|
||||
"Created native provider: {} ({})",
|
||||
provider.name(),
|
||||
provider.type_id()
|
||||
);
|
||||
providers.push(provider);
|
||||
}
|
||||
}
|
||||
providers
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to discover native plugins: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
Self::new(core_providers, native_providers)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn is_dmenu_mode(&self) -> bool {
|
||||
self.providers
|
||||
.iter()
|
||||
.any(|p| p.provider_type() == ProviderType::Dmenu)
|
||||
}
|
||||
|
||||
pub fn refresh_all(&mut self) {
|
||||
// Refresh core providers (apps, commands)
|
||||
for provider in &mut self.providers {
|
||||
provider.refresh();
|
||||
info!(
|
||||
"Provider '{}' loaded {} items",
|
||||
provider.name(),
|
||||
provider.items().len()
|
||||
);
|
||||
}
|
||||
|
||||
// Refresh static native providers (clipboard, emoji, ssh, etc.)
|
||||
for provider in &mut self.static_native_providers {
|
||||
provider.refresh();
|
||||
info!(
|
||||
"Static provider '{}' loaded {} items",
|
||||
provider.name(),
|
||||
provider.items().len()
|
||||
);
|
||||
}
|
||||
|
||||
// Widget providers are refreshed separately to avoid blocking startup
|
||||
// Call refresh_widgets() after window is shown
|
||||
|
||||
// Dynamic providers don't need refresh (they query on demand)
|
||||
}
|
||||
|
||||
/// Refresh widget providers (weather, media, pomodoro)
|
||||
/// Call this separately from refresh_all() to avoid blocking startup
|
||||
/// since widgets may make network requests or spawn processes
|
||||
pub fn refresh_widgets(&mut self) {
|
||||
for provider in &mut self.widget_providers {
|
||||
provider.refresh();
|
||||
info!(
|
||||
"Widget '{}' loaded {} items",
|
||||
provider.name(),
|
||||
provider.items().len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a native provider by type ID
|
||||
/// Searches in all native provider lists (static, dynamic, widget)
|
||||
pub fn find_native_provider(&self, type_id: &str) -> Option<&NativeProvider> {
|
||||
// Check static native providers first (clipboard, emoji, ssh, systemd, etc.)
|
||||
if let Some(p) = self
|
||||
.static_native_providers
|
||||
.iter()
|
||||
.find(|p| p.type_id() == type_id)
|
||||
{
|
||||
return Some(p);
|
||||
}
|
||||
// Check widget providers (pomodoro, weather, media)
|
||||
if let Some(p) = self
|
||||
.widget_providers
|
||||
.iter()
|
||||
.find(|p| p.type_id() == type_id)
|
||||
{
|
||||
return Some(p);
|
||||
}
|
||||
// Then dynamic providers (calc, websearch, filesearch)
|
||||
self.dynamic_providers
|
||||
.iter()
|
||||
.find(|p| p.type_id() == type_id)
|
||||
}
|
||||
|
||||
/// Execute a plugin action command
|
||||
/// Command format: PLUGIN_ID:action_data (e.g., "POMODORO:start", "SYSTEMD:unit:restart")
|
||||
/// Returns true if the command was handled by a plugin
|
||||
pub fn execute_plugin_action(&self, command: &str) -> bool {
|
||||
// Parse command format: PLUGIN_ID:action_data
|
||||
if let Some(colon_pos) = command.find(':') {
|
||||
let plugin_id = &command[..colon_pos];
|
||||
let action = command; // Pass full command to plugin
|
||||
|
||||
// Find provider by type ID (case-insensitive for convenience)
|
||||
let type_id = plugin_id.to_lowercase();
|
||||
|
||||
if let Some(provider) = self.find_native_provider(&type_id) {
|
||||
provider.execute_action(action);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Add a dynamic provider (e.g., from a Lua plugin)
|
||||
#[allow(dead_code)]
|
||||
pub fn add_provider(&mut self, provider: Box<dyn Provider>) {
|
||||
info!("Added plugin provider: {}", provider.name());
|
||||
self.providers.push(provider);
|
||||
}
|
||||
|
||||
/// Add multiple providers at once (for batch plugin loading)
|
||||
#[allow(dead_code)]
|
||||
pub fn add_providers(&mut self, providers: Vec<Box<dyn Provider>>) {
|
||||
for provider in providers {
|
||||
self.add_provider(provider);
|
||||
}
|
||||
}
|
||||
|
||||
/// Iterate over all static provider items (core + native static plugins)
|
||||
fn all_static_items(&self) -> impl Iterator<Item = &LaunchItem> {
|
||||
self.providers.iter().flat_map(|p| p.items().iter()).chain(
|
||||
self.static_native_providers
|
||||
.iter()
|
||||
.flat_map(|p| p.items().iter()),
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn search(&self, query: &str, max_results: usize) -> Vec<(LaunchItem, i64)> {
|
||||
if query.is_empty() {
|
||||
// Return recent/popular items when query is empty
|
||||
return self
|
||||
.all_static_items()
|
||||
.take(max_results)
|
||||
.map(|item| (item.clone(), 0))
|
||||
.collect();
|
||||
}
|
||||
|
||||
let mut results: Vec<(LaunchItem, i64)> = self
|
||||
.all_static_items()
|
||||
.filter_map(|item| {
|
||||
// Match against name and description
|
||||
let name_score = self.matcher.fuzzy_match(&item.name, query);
|
||||
let desc_score = item
|
||||
.description
|
||||
.as_ref()
|
||||
.and_then(|d| self.matcher.fuzzy_match(d, query));
|
||||
|
||||
let score = match (name_score, desc_score) {
|
||||
(Some(n), Some(d)) => Some(n.max(d)),
|
||||
(Some(n), None) => Some(n),
|
||||
(None, Some(d)) => Some(d / 2), // Lower weight for description matches
|
||||
(None, None) => None,
|
||||
};
|
||||
|
||||
score.map(|s| (item.clone(), s))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort by score (descending)
|
||||
results.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
results.truncate(max_results);
|
||||
results
|
||||
}
|
||||
|
||||
/// Search with provider filtering
|
||||
pub fn search_filtered(
|
||||
&self,
|
||||
query: &str,
|
||||
max_results: usize,
|
||||
filter: &crate::filter::ProviderFilter,
|
||||
) -> Vec<(LaunchItem, i64)> {
|
||||
// Collect items from core providers
|
||||
let core_items = self
|
||||
.providers
|
||||
.iter()
|
||||
.filter(|p| filter.is_active(p.provider_type()))
|
||||
.flat_map(|p| p.items().iter().cloned());
|
||||
|
||||
// Collect items from static native providers
|
||||
let native_items = self
|
||||
.static_native_providers
|
||||
.iter()
|
||||
.filter(|p| filter.is_active(p.provider_type()))
|
||||
.flat_map(|p| p.items().iter().cloned());
|
||||
|
||||
if query.is_empty() {
|
||||
return core_items
|
||||
.chain(native_items)
|
||||
.take(max_results)
|
||||
.map(|item| (item, 0))
|
||||
.collect();
|
||||
}
|
||||
|
||||
let mut results: Vec<(LaunchItem, i64)> = core_items
|
||||
.chain(native_items)
|
||||
.filter_map(|item| {
|
||||
let name_score = self.matcher.fuzzy_match(&item.name, query);
|
||||
let desc_score = item
|
||||
.description
|
||||
.as_ref()
|
||||
.and_then(|d| self.matcher.fuzzy_match(d, query));
|
||||
|
||||
let score = match (name_score, desc_score) {
|
||||
(Some(n), Some(d)) => Some(n.max(d)),
|
||||
(Some(n), None) => Some(n),
|
||||
(None, Some(d)) => Some(d / 2),
|
||||
(None, None) => None,
|
||||
};
|
||||
|
||||
score.map(|s| (item, s))
|
||||
})
|
||||
.collect();
|
||||
|
||||
results.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
results.truncate(max_results);
|
||||
results
|
||||
}
|
||||
|
||||
/// Search with frecency boosting, dynamic providers, and tag filtering
|
||||
pub fn search_with_frecency(
|
||||
&self,
|
||||
query: &str,
|
||||
max_results: usize,
|
||||
filter: &crate::filter::ProviderFilter,
|
||||
frecency: &FrecencyStore,
|
||||
frecency_weight: f64,
|
||||
tag_filter: Option<&str>,
|
||||
) -> Vec<(LaunchItem, i64)> {
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"[Search] query={:?}, max={}, frecency_weight={}",
|
||||
query, max_results, frecency_weight
|
||||
);
|
||||
|
||||
let mut results: Vec<(LaunchItem, i64)> = Vec::new();
|
||||
|
||||
// Add widget items first (highest priority) - only when:
|
||||
// 1. No specific filter prefix is active
|
||||
// 2. Query is empty (user hasn't started searching)
|
||||
// This keeps widgets visible on launch but hides them during active search
|
||||
// Widgets are always visible regardless of filter settings (they declare position via API)
|
||||
if filter.active_prefix().is_none() && query.is_empty() {
|
||||
// Widget priority comes from plugin-declared priority field
|
||||
for provider in &self.widget_providers {
|
||||
let base_score = provider.priority() as i64;
|
||||
for (idx, item) in provider.items().iter().enumerate() {
|
||||
results.push((item.clone(), base_score - idx as i64));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Query dynamic providers (calculator, websearch, filesearch)
|
||||
// Only query if:
|
||||
// 1. Their specific filter is active (e.g., :file prefix or Files tab selected), OR
|
||||
// 2. No specific single-mode filter is active (showing all providers)
|
||||
if !query.is_empty() {
|
||||
for provider in &self.dynamic_providers {
|
||||
// Skip if this provider type is explicitly filtered out
|
||||
if !filter.is_active(provider.provider_type()) {
|
||||
continue;
|
||||
}
|
||||
let dynamic_results = provider.query(query);
|
||||
// Priority comes from plugin-declared priority field
|
||||
let base_score = provider.priority() as i64;
|
||||
for (idx, item) in dynamic_results.into_iter().enumerate() {
|
||||
results.push((item, base_score - idx as i64));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty query (after checking special providers) - return frecency-sorted items
|
||||
if query.is_empty() {
|
||||
// Collect items from core providers
|
||||
let core_items = self
|
||||
.providers
|
||||
.iter()
|
||||
.filter(|p| filter.is_active(p.provider_type()))
|
||||
.flat_map(|p| p.items().iter().cloned());
|
||||
|
||||
// Collect items from static native providers
|
||||
let native_items = self
|
||||
.static_native_providers
|
||||
.iter()
|
||||
.filter(|p| filter.is_active(p.provider_type()))
|
||||
.flat_map(|p| p.items().iter().cloned());
|
||||
|
||||
let items: Vec<(LaunchItem, i64)> = core_items
|
||||
.chain(native_items)
|
||||
.filter(|item| {
|
||||
// Apply tag filter if present
|
||||
if let Some(tag) = tag_filter {
|
||||
item.tags.iter().any(|t| t.to_lowercase().contains(tag))
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.map(|item| {
|
||||
let frecency_score = frecency.get_score(&item.id);
|
||||
let boosted = (frecency_score * frecency_weight * 100.0) as i64;
|
||||
(item, boosted)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Combine widgets (already in results) with frecency items
|
||||
results.extend(items);
|
||||
results.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
results.truncate(max_results);
|
||||
return results;
|
||||
}
|
||||
|
||||
// Regular search with frecency boost and tag matching
|
||||
// Helper closure for scoring items
|
||||
let score_item = |item: &LaunchItem| -> Option<(LaunchItem, i64)> {
|
||||
// Apply tag filter if present
|
||||
if let Some(tag) = tag_filter
|
||||
&& !item.tags.iter().any(|t| t.to_lowercase().contains(tag))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let name_score = self.matcher.fuzzy_match(&item.name, query);
|
||||
let desc_score = item
|
||||
.description
|
||||
.as_ref()
|
||||
.and_then(|d| self.matcher.fuzzy_match(d, query));
|
||||
|
||||
// Also match against tags (lower weight)
|
||||
let tag_score = item
|
||||
.tags
|
||||
.iter()
|
||||
.filter_map(|t| self.matcher.fuzzy_match(t, query))
|
||||
.max()
|
||||
.map(|s| s / 3); // Lower weight for tag matches
|
||||
|
||||
let base_score = match (name_score, desc_score, tag_score) {
|
||||
(Some(n), Some(d), Some(t)) => Some(n.max(d).max(t)),
|
||||
(Some(n), Some(d), None) => Some(n.max(d)),
|
||||
(Some(n), None, Some(t)) => Some(n.max(t)),
|
||||
(Some(n), None, None) => Some(n),
|
||||
(None, Some(d), Some(t)) => Some((d / 2).max(t)),
|
||||
(None, Some(d), None) => Some(d / 2),
|
||||
(None, None, Some(t)) => Some(t),
|
||||
(None, None, None) => None,
|
||||
};
|
||||
|
||||
base_score.map(|s| {
|
||||
let frecency_score = frecency.get_score(&item.id);
|
||||
let frecency_boost = (frecency_score * frecency_weight * 10.0) as i64;
|
||||
(item.clone(), s + frecency_boost)
|
||||
})
|
||||
};
|
||||
|
||||
// Search core providers
|
||||
for provider in &self.providers {
|
||||
if !filter.is_active(provider.provider_type()) {
|
||||
continue;
|
||||
}
|
||||
for item in provider.items() {
|
||||
if let Some(scored) = score_item(item) {
|
||||
results.push(scored);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search static native providers
|
||||
for provider in &self.static_native_providers {
|
||||
if !filter.is_active(provider.provider_type()) {
|
||||
continue;
|
||||
}
|
||||
for item in provider.items() {
|
||||
if let Some(scored) = score_item(item) {
|
||||
results.push(scored);
|
||||
}
|
||||
}
|
||||
}
|
||||
results.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
results.truncate(max_results);
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
{
|
||||
debug!("[Search] Returning {} results", results.len());
|
||||
for (i, (item, score)) in results.iter().take(5).enumerate() {
|
||||
debug!(
|
||||
"[Search] #{}: {} (score={}, provider={:?})",
|
||||
i + 1,
|
||||
item.name,
|
||||
score,
|
||||
item.provider
|
||||
);
|
||||
}
|
||||
if results.len() > 5 {
|
||||
debug!("[Search] ... and {} more", results.len() - 5);
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
/// Get all available provider types (for UI tabs)
|
||||
#[allow(dead_code)]
|
||||
pub fn available_provider_types(&self) -> Vec<ProviderType> {
|
||||
self.providers
|
||||
.iter()
|
||||
.map(|p| p.provider_type())
|
||||
.chain(
|
||||
self.static_native_providers
|
||||
.iter()
|
||||
.map(|p| p.provider_type()),
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get descriptors for all registered providers (core + native plugins).
|
||||
///
|
||||
/// Used by the IPC server to report what providers are available to clients.
|
||||
pub fn available_providers(&self) -> Vec<ProviderDescriptor> {
|
||||
let mut descs = Vec::new();
|
||||
|
||||
// Core providers
|
||||
for provider in &self.providers {
|
||||
let (id, prefix, icon) = match provider.provider_type() {
|
||||
ProviderType::Application => (
|
||||
"app".to_string(),
|
||||
Some(":app".to_string()),
|
||||
"application-x-executable".to_string(),
|
||||
),
|
||||
ProviderType::Command => (
|
||||
"cmd".to_string(),
|
||||
Some(":cmd".to_string()),
|
||||
"utilities-terminal".to_string(),
|
||||
),
|
||||
ProviderType::Dmenu => {
|
||||
("dmenu".to_string(), None, "view-list-symbolic".to_string())
|
||||
}
|
||||
ProviderType::Plugin(type_id) => (type_id, None, "application-x-addon".to_string()),
|
||||
};
|
||||
descs.push(ProviderDescriptor {
|
||||
id,
|
||||
name: provider.name().to_string(),
|
||||
prefix,
|
||||
icon,
|
||||
position: "normal".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Static native plugin providers
|
||||
for provider in &self.static_native_providers {
|
||||
descs.push(ProviderDescriptor {
|
||||
id: provider.type_id().to_string(),
|
||||
name: provider.name().to_string(),
|
||||
prefix: provider.prefix().map(String::from),
|
||||
icon: provider.icon().to_string(),
|
||||
position: provider.position_str().to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Dynamic native plugin providers
|
||||
for provider in &self.dynamic_providers {
|
||||
descs.push(ProviderDescriptor {
|
||||
id: provider.type_id().to_string(),
|
||||
name: provider.name().to_string(),
|
||||
prefix: provider.prefix().map(String::from),
|
||||
icon: provider.icon().to_string(),
|
||||
position: provider.position_str().to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Widget native plugin providers
|
||||
for provider in &self.widget_providers {
|
||||
descs.push(ProviderDescriptor {
|
||||
id: provider.type_id().to_string(),
|
||||
name: provider.name().to_string(),
|
||||
prefix: provider.prefix().map(String::from),
|
||||
icon: provider.icon().to_string(),
|
||||
position: provider.position_str().to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
descs
|
||||
}
|
||||
|
||||
/// Refresh a specific provider by its type_id.
|
||||
///
|
||||
/// Searches core providers (by ProviderType string), static native providers,
|
||||
/// and widget providers. Dynamic providers are skipped (they query on demand).
|
||||
pub fn refresh_provider(&mut self, provider_id: &str) {
|
||||
// Check core providers
|
||||
for provider in &mut self.providers {
|
||||
let matches = match provider.provider_type() {
|
||||
ProviderType::Application => provider_id == "app",
|
||||
ProviderType::Command => provider_id == "cmd",
|
||||
ProviderType::Dmenu => provider_id == "dmenu",
|
||||
ProviderType::Plugin(ref id) => provider_id == id,
|
||||
};
|
||||
if matches {
|
||||
provider.refresh();
|
||||
info!("Refreshed core provider '{}'", provider.name());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check static native providers
|
||||
for provider in &mut self.static_native_providers {
|
||||
if provider.type_id() == provider_id {
|
||||
provider.refresh();
|
||||
info!("Refreshed static provider '{}'", provider.name());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check widget providers
|
||||
for provider in &mut self.widget_providers {
|
||||
if provider.type_id() == provider_id {
|
||||
provider.refresh();
|
||||
info!("Refreshed widget provider '{}'", provider.name());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
info!("Provider '{}' not found for refresh", provider_id);
|
||||
}
|
||||
|
||||
/// Get a widget item by type_id (e.g., "pomodoro", "weather", "media")
|
||||
/// Returns the first item from the widget provider, if any
|
||||
pub fn get_widget_item(&self, type_id: &str) -> Option<LaunchItem> {
|
||||
self.widget_providers
|
||||
.iter()
|
||||
.find(|p| p.type_id() == type_id)
|
||||
.and_then(|p| p.items().first().cloned())
|
||||
}
|
||||
|
||||
/// Get all loaded widget provider type_ids
|
||||
/// Returns an iterator over the type_ids of currently loaded widget providers
|
||||
pub fn widget_type_ids(&self) -> impl Iterator<Item = &str> {
|
||||
self.widget_providers.iter().map(|p| p.type_id())
|
||||
}
|
||||
|
||||
/// Query a plugin for submenu actions
|
||||
///
|
||||
/// This is used when a user selects a SUBMENU:plugin_id:data item.
|
||||
/// The plugin is queried with "?SUBMENU:data" and returns action items.
|
||||
///
|
||||
/// Returns (display_name, actions) where display_name is the item name
|
||||
/// and actions are the submenu items returned by the plugin.
|
||||
pub fn query_submenu_actions(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
data: &str,
|
||||
display_name: &str,
|
||||
) -> Option<(String, Vec<LaunchItem>)> {
|
||||
// Build the submenu query
|
||||
let submenu_query = format!("?SUBMENU:{}", data);
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"[Submenu] Querying plugin '{}' with: {}",
|
||||
plugin_id, submenu_query
|
||||
);
|
||||
|
||||
// Search in static native providers (clipboard, emoji, ssh, systemd, etc.)
|
||||
for provider in &self.static_native_providers {
|
||||
if provider.type_id() == plugin_id {
|
||||
let actions = provider.query(&submenu_query);
|
||||
if !actions.is_empty() {
|
||||
return Some((display_name.to_string(), actions));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search in dynamic providers
|
||||
for provider in &self.dynamic_providers {
|
||||
if provider.type_id() == plugin_id {
|
||||
let actions = provider.query(&submenu_query);
|
||||
if !actions.is_empty() {
|
||||
return Some((display_name.to_string(), actions));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search in widget providers
|
||||
for provider in &self.widget_providers {
|
||||
if provider.type_id() == plugin_id {
|
||||
let actions = provider.query(&submenu_query);
|
||||
if !actions.is_empty() {
|
||||
return Some((display_name.to_string(), actions));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"[Submenu] No submenu actions found for plugin '{}'",
|
||||
plugin_id
|
||||
);
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Minimal mock provider for testing ProviderManager
|
||||
struct MockProvider {
|
||||
name: String,
|
||||
provider_type: ProviderType,
|
||||
items: Vec<LaunchItem>,
|
||||
refresh_count: usize,
|
||||
}
|
||||
|
||||
impl MockProvider {
|
||||
fn new(name: &str, provider_type: ProviderType) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
provider_type,
|
||||
items: Vec::new(),
|
||||
refresh_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_items(mut self, items: Vec<LaunchItem>) -> Self {
|
||||
self.items = items;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Provider for MockProvider {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn provider_type(&self) -> ProviderType {
|
||||
self.provider_type.clone()
|
||||
}
|
||||
|
||||
fn refresh(&mut self) {
|
||||
self.refresh_count += 1;
|
||||
}
|
||||
|
||||
fn items(&self) -> &[LaunchItem] {
|
||||
&self.items
|
||||
}
|
||||
}
|
||||
|
||||
fn make_item(id: &str, name: &str, provider: ProviderType) -> LaunchItem {
|
||||
LaunchItem {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
description: None,
|
||||
icon: None,
|
||||
provider,
|
||||
command: format!("run-{}", id),
|
||||
terminal: false,
|
||||
tags: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_available_providers_core_only() {
|
||||
let providers: Vec<Box<dyn Provider>> = vec![
|
||||
Box::new(MockProvider::new("Applications", ProviderType::Application)),
|
||||
Box::new(MockProvider::new("Commands", ProviderType::Command)),
|
||||
];
|
||||
let pm = ProviderManager::new(providers, Vec::new());
|
||||
let descs = pm.available_providers();
|
||||
assert_eq!(descs.len(), 2);
|
||||
assert_eq!(descs[0].id, "app");
|
||||
assert_eq!(descs[0].name, "Applications");
|
||||
assert_eq!(descs[0].prefix, Some(":app".to_string()));
|
||||
assert_eq!(descs[0].icon, "application-x-executable");
|
||||
assert_eq!(descs[0].position, "normal");
|
||||
assert_eq!(descs[1].id, "cmd");
|
||||
assert_eq!(descs[1].name, "Commands");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_available_providers_dmenu() {
|
||||
let providers: Vec<Box<dyn Provider>> =
|
||||
vec![Box::new(MockProvider::new("dmenu", ProviderType::Dmenu))];
|
||||
let pm = ProviderManager::new(providers, Vec::new());
|
||||
let descs = pm.available_providers();
|
||||
assert_eq!(descs.len(), 1);
|
||||
assert_eq!(descs[0].id, "dmenu");
|
||||
assert!(descs[0].prefix.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_available_provider_types() {
|
||||
let providers: Vec<Box<dyn Provider>> = vec![
|
||||
Box::new(MockProvider::new("Applications", ProviderType::Application)),
|
||||
Box::new(MockProvider::new("Commands", ProviderType::Command)),
|
||||
];
|
||||
let pm = ProviderManager::new(providers, Vec::new());
|
||||
let types = pm.available_provider_types();
|
||||
assert_eq!(types.len(), 2);
|
||||
assert!(types.contains(&ProviderType::Application));
|
||||
assert!(types.contains(&ProviderType::Command));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_refresh_provider_core() {
|
||||
let app = MockProvider::new("Applications", ProviderType::Application);
|
||||
let cmd = MockProvider::new("Commands", ProviderType::Command);
|
||||
let providers: Vec<Box<dyn Provider>> = vec![Box::new(app), Box::new(cmd)];
|
||||
let mut pm = ProviderManager::new(providers, Vec::new());
|
||||
|
||||
// refresh_all was called during construction, now refresh individual
|
||||
pm.refresh_provider("app");
|
||||
pm.refresh_provider("cmd");
|
||||
// Just verifying it doesn't panic; can't easily inspect refresh_count
|
||||
// through Box<dyn Provider>
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_refresh_provider_unknown_does_not_panic() {
|
||||
let providers: Vec<Box<dyn Provider>> = vec![Box::new(MockProvider::new(
|
||||
"Applications",
|
||||
ProviderType::Application,
|
||||
))];
|
||||
let mut pm = ProviderManager::new(providers, Vec::new());
|
||||
pm.refresh_provider("nonexistent");
|
||||
// Should complete without panicking
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_with_core_providers() {
|
||||
let items = vec![
|
||||
make_item("firefox", "Firefox", ProviderType::Application),
|
||||
make_item("vim", "Vim", ProviderType::Application),
|
||||
];
|
||||
let provider =
|
||||
MockProvider::new("Applications", ProviderType::Application).with_items(items);
|
||||
let providers: Vec<Box<dyn Provider>> = vec![Box::new(provider)];
|
||||
let pm = ProviderManager::new(providers, Vec::new());
|
||||
|
||||
let results = pm.search("fire", 10);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].0.name, "Firefox");
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,9 @@
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use log::debug;
|
||||
use owlry_plugin_api::{PluginItem as ApiPluginItem, ProviderHandle, ProviderInfo, ProviderKind};
|
||||
use owlry_plugin_api::{
|
||||
PluginItem as ApiPluginItem, ProviderHandle, ProviderInfo, ProviderKind, ProviderPosition,
|
||||
};
|
||||
|
||||
use super::{LaunchItem, Provider, ProviderType};
|
||||
use crate::plugins::native_loader::NativePlugin;
|
||||
@@ -43,13 +45,9 @@ impl NativeProvider {
|
||||
}
|
||||
|
||||
/// Get the ProviderType for this native provider
|
||||
/// Maps type_id string to the appropriate ProviderType variant
|
||||
/// All native plugins return Plugin(type_id) - the core has no hardcoded plugin types
|
||||
fn get_provider_type(&self) -> ProviderType {
|
||||
// Parse type_id to get the proper ProviderType
|
||||
// This uses the FromStr impl which maps strings like "clipboard" -> ProviderType::Clipboard
|
||||
self.info.type_id.as_str().parse().unwrap_or_else(|_| {
|
||||
ProviderType::Plugin(self.info.type_id.to_string())
|
||||
})
|
||||
ProviderType::Plugin(self.info.type_id.to_string())
|
||||
}
|
||||
|
||||
/// Convert a plugin API item to a core LaunchItem
|
||||
@@ -80,7 +78,10 @@ impl NativeProvider {
|
||||
}
|
||||
|
||||
let api_items = self.plugin.query_provider(self.handle, query);
|
||||
api_items.into_iter().map(|item| self.convert_item(item)).collect()
|
||||
api_items
|
||||
.into_iter()
|
||||
.map(|item| self.convert_item(item))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Check if this provider has a prefix that matches the query
|
||||
@@ -109,6 +110,30 @@ impl NativeProvider {
|
||||
self.info.type_id.as_str()
|
||||
}
|
||||
|
||||
/// Check if this is a widget provider (appears at top of results)
|
||||
pub fn is_widget(&self) -> bool {
|
||||
self.info.position == ProviderPosition::Widget
|
||||
}
|
||||
|
||||
/// Get the provider's priority for result ordering
|
||||
/// Higher values appear first in results
|
||||
pub fn priority(&self) -> i32 {
|
||||
self.info.priority
|
||||
}
|
||||
|
||||
/// Get the provider's default icon name
|
||||
pub fn icon(&self) -> &str {
|
||||
self.info.icon.as_str()
|
||||
}
|
||||
|
||||
/// Get the provider's display position as a string
|
||||
pub fn position_str(&self) -> &str {
|
||||
match self.info.position {
|
||||
ProviderPosition::Widget => "widget",
|
||||
ProviderPosition::Normal => "normal",
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute an action command on the provider
|
||||
/// Uses query with "!" prefix to trigger action handling in the plugin
|
||||
pub fn execute_action(&self, action: &str) {
|
||||
258
crates/owlry-core/src/server.rs
Normal file
@@ -0,0 +1,258 @@
|
||||
use std::io::{self, BufRead, BufReader, Write};
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
|
||||
use log::{error, info, warn};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::data::FrecencyStore;
|
||||
use crate::filter::ProviderFilter;
|
||||
use crate::ipc::{ProviderDesc, Request, Response, ResultItem};
|
||||
use crate::providers::{LaunchItem, ProviderManager};
|
||||
|
||||
/// IPC server that listens on a Unix domain socket and dispatches
|
||||
/// requests to the provider system.
|
||||
pub struct Server {
|
||||
listener: UnixListener,
|
||||
socket_path: PathBuf,
|
||||
provider_manager: Arc<Mutex<ProviderManager>>,
|
||||
frecency: Arc<Mutex<FrecencyStore>>,
|
||||
config: Arc<Config>,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
/// Bind to the given socket path, loading config and creating a ProviderManager.
|
||||
///
|
||||
/// Removes a stale socket file if one already exists at the path.
|
||||
pub fn bind(socket_path: &Path) -> io::Result<Self> {
|
||||
// Remove stale socket if present
|
||||
if socket_path.exists() {
|
||||
info!("Removing stale socket at {:?}", socket_path);
|
||||
std::fs::remove_file(socket_path)?;
|
||||
}
|
||||
|
||||
let listener = UnixListener::bind(socket_path)?;
|
||||
info!("IPC server listening on {:?}", socket_path);
|
||||
|
||||
let config = Config::load_or_default();
|
||||
let provider_manager = ProviderManager::new_with_config(&config);
|
||||
let frecency = FrecencyStore::new();
|
||||
|
||||
Ok(Self {
|
||||
listener,
|
||||
socket_path: socket_path.to_path_buf(),
|
||||
provider_manager: Arc::new(Mutex::new(provider_manager)),
|
||||
frecency: Arc::new(Mutex::new(frecency)),
|
||||
config: Arc::new(config),
|
||||
})
|
||||
}
|
||||
|
||||
/// Accept connections in a loop, spawning a thread per client.
|
||||
pub fn run(&self) -> io::Result<()> {
|
||||
info!("Server entering accept loop");
|
||||
for stream in self.listener.incoming() {
|
||||
match stream {
|
||||
Ok(stream) => {
|
||||
let pm = Arc::clone(&self.provider_manager);
|
||||
let frecency = Arc::clone(&self.frecency);
|
||||
let config = Arc::clone(&self.config);
|
||||
thread::spawn(move || {
|
||||
if let Err(e) = Self::handle_client(stream, pm, frecency, config) {
|
||||
warn!("Client handler error: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to accept connection: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Accept one connection and handle all its requests until EOF.
|
||||
///
|
||||
/// Intended for integration tests where spawning a full accept loop
|
||||
/// is unnecessary.
|
||||
pub fn handle_one_for_testing(&self) -> io::Result<()> {
|
||||
let (stream, _addr) = self.listener.accept()?;
|
||||
Self::handle_client(
|
||||
stream,
|
||||
Arc::clone(&self.provider_manager),
|
||||
Arc::clone(&self.frecency),
|
||||
Arc::clone(&self.config),
|
||||
)
|
||||
}
|
||||
|
||||
/// Read newline-delimited JSON requests from a single client stream,
|
||||
/// dispatch each, and write the JSON response back.
|
||||
fn handle_client(
|
||||
stream: UnixStream,
|
||||
pm: Arc<Mutex<ProviderManager>>,
|
||||
frecency: Arc<Mutex<FrecencyStore>>,
|
||||
config: Arc<Config>,
|
||||
) -> io::Result<()> {
|
||||
let reader = BufReader::new(stream.try_clone()?);
|
||||
let mut writer = stream;
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = line?;
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let request: Request = match serde_json::from_str(trimmed) {
|
||||
Ok(req) => req,
|
||||
Err(e) => {
|
||||
let resp = Response::Error {
|
||||
message: format!("invalid request JSON: {}", e),
|
||||
};
|
||||
write_response(&mut writer, &resp)?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let response = Self::handle_request(&request, &pm, &frecency, &config);
|
||||
write_response(&mut writer, &response)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dispatch a single request to the appropriate subsystem and return
|
||||
/// the response.
|
||||
fn handle_request(
|
||||
request: &Request,
|
||||
pm: &Arc<Mutex<ProviderManager>>,
|
||||
frecency: &Arc<Mutex<FrecencyStore>>,
|
||||
config: &Arc<Config>,
|
||||
) -> Response {
|
||||
match request {
|
||||
Request::Query { text, modes } => {
|
||||
let filter = match modes {
|
||||
Some(m) => ProviderFilter::from_mode_strings(m),
|
||||
None => ProviderFilter::all(),
|
||||
};
|
||||
let max = config.general.max_results;
|
||||
let weight = config.providers.frecency_weight;
|
||||
|
||||
let pm_guard = pm.lock().unwrap();
|
||||
let frecency_guard = frecency.lock().unwrap();
|
||||
let results = pm_guard.search_with_frecency(
|
||||
text,
|
||||
max,
|
||||
&filter,
|
||||
&frecency_guard,
|
||||
weight,
|
||||
None,
|
||||
);
|
||||
|
||||
Response::Results {
|
||||
items: results
|
||||
.into_iter()
|
||||
.map(|(item, score)| launch_item_to_result(item, score))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
Request::Launch {
|
||||
item_id,
|
||||
provider: _,
|
||||
} => {
|
||||
let mut frecency_guard = frecency.lock().unwrap();
|
||||
frecency_guard.record_launch(item_id);
|
||||
Response::Ack
|
||||
}
|
||||
|
||||
Request::Providers => {
|
||||
let pm_guard = pm.lock().unwrap();
|
||||
let descs = pm_guard.available_providers();
|
||||
Response::Providers {
|
||||
list: descs.into_iter().map(descriptor_to_desc).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
Request::Refresh { provider } => {
|
||||
let mut pm_guard = pm.lock().unwrap();
|
||||
pm_guard.refresh_provider(provider);
|
||||
Response::Ack
|
||||
}
|
||||
|
||||
Request::Toggle => {
|
||||
// Toggle visibility is a client-side concern; the daemon just acks.
|
||||
Response::Ack
|
||||
}
|
||||
|
||||
Request::Submenu { plugin_id, data } => {
|
||||
let pm_guard = pm.lock().unwrap();
|
||||
match pm_guard.query_submenu_actions(plugin_id, data, plugin_id) {
|
||||
Some((_name, actions)) => Response::SubmenuItems {
|
||||
items: actions
|
||||
.into_iter()
|
||||
.map(|item| launch_item_to_result(item, 0))
|
||||
.collect(),
|
||||
},
|
||||
None => Response::Error {
|
||||
message: format!("no submenu actions for plugin '{}'", plugin_id),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Request::PluginAction { command } => {
|
||||
let pm_guard = pm.lock().unwrap();
|
||||
if pm_guard.execute_plugin_action(command) {
|
||||
Response::Ack
|
||||
} else {
|
||||
Response::Error {
|
||||
message: format!("no plugin handled action '{}'", command),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
// Best-effort cleanup of the socket file
|
||||
if self.socket_path.exists() {
|
||||
let _ = std::fs::remove_file(&self.socket_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize a response as a single JSON line terminated by newline.
|
||||
fn write_response(writer: &mut UnixStream, response: &Response) -> io::Result<()> {
|
||||
let mut json = serde_json::to_string(response)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
json.push('\n');
|
||||
writer.write_all(json.as_bytes())?;
|
||||
writer.flush()
|
||||
}
|
||||
|
||||
fn launch_item_to_result(item: LaunchItem, score: i64) -> ResultItem {
|
||||
ResultItem {
|
||||
id: item.id,
|
||||
title: item.name,
|
||||
description: item.description.unwrap_or_default(),
|
||||
icon: item.icon.unwrap_or_default(),
|
||||
provider: format!("{}", item.provider),
|
||||
score,
|
||||
command: Some(item.command),
|
||||
terminal: item.terminal,
|
||||
tags: item.tags,
|
||||
}
|
||||
}
|
||||
|
||||
fn descriptor_to_desc(desc: crate::providers::ProviderDescriptor) -> ProviderDesc {
|
||||
ProviderDesc {
|
||||
id: desc.id,
|
||||
name: desc.name,
|
||||
prefix: desc.prefix,
|
||||
icon: desc.icon,
|
||||
position: desc.position,
|
||||
}
|
||||
}
|
||||
148
crates/owlry-core/tests/ipc_test.rs
Normal file
@@ -0,0 +1,148 @@
|
||||
use owlry_core::ipc::{ProviderDesc, Request, Response, ResultItem};
|
||||
|
||||
#[test]
|
||||
fn test_query_request_roundtrip() {
|
||||
let req = Request::Query {
|
||||
text: "fire".into(),
|
||||
modes: Some(vec!["app".into(), "cmd".into()]),
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
let parsed: Request = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(req, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_request_without_modes() {
|
||||
let req = Request::Query {
|
||||
text: "fire".into(),
|
||||
modes: None,
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
assert!(!json.contains("modes"));
|
||||
let parsed: Request = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(req, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_launch_request_roundtrip() {
|
||||
let req = Request::Launch {
|
||||
item_id: "firefox.desktop".into(),
|
||||
provider: "app".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
let parsed: Request = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(req, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_results_response_roundtrip() {
|
||||
let resp = Response::Results {
|
||||
items: vec![ResultItem {
|
||||
id: "firefox.desktop".into(),
|
||||
title: "Firefox".into(),
|
||||
description: "Web Browser".into(),
|
||||
icon: "firefox".into(),
|
||||
provider: "app".into(),
|
||||
score: 95,
|
||||
command: Some("firefox".into()),
|
||||
terminal: false,
|
||||
tags: vec![],
|
||||
}],
|
||||
};
|
||||
let json = serde_json::to_string(&resp).unwrap();
|
||||
let parsed: Response = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(resp, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_providers_response() {
|
||||
let resp = Response::Providers {
|
||||
list: vec![ProviderDesc {
|
||||
id: "app".into(),
|
||||
name: "Applications".into(),
|
||||
prefix: Some(":app".into()),
|
||||
icon: "application-x-executable".into(),
|
||||
position: "normal".into(),
|
||||
}],
|
||||
};
|
||||
let json = serde_json::to_string(&resp).unwrap();
|
||||
let parsed: Response = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(resp, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_response() {
|
||||
let resp = Response::Error {
|
||||
message: "plugin not found".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&resp).unwrap();
|
||||
let parsed: Response = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(resp, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toggle_request() {
|
||||
let req = Request::Toggle;
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
let parsed: Request = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(req, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_submenu_request() {
|
||||
let req = Request::Submenu {
|
||||
plugin_id: "systemd".into(),
|
||||
data: "docker.service".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
let parsed: Request = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(req, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_refresh_request() {
|
||||
let req = Request::Refresh {
|
||||
provider: "clipboard".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
let parsed: Request = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(req, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plugin_action_request() {
|
||||
let req = Request::PluginAction {
|
||||
command: "POMODORO:start".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
let parsed: Request = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(req, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_terminal_field_defaults_false() {
|
||||
// terminal field should default to false when missing from JSON
|
||||
let json =
|
||||
r#"{"id":"test","title":"Test","description":"","icon":"","provider":"cmd","score":0}"#;
|
||||
let item: ResultItem = serde_json::from_str(json).unwrap();
|
||||
assert!(!item.terminal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_terminal_field_roundtrip() {
|
||||
let item = ResultItem {
|
||||
id: "htop".into(),
|
||||
title: "htop".into(),
|
||||
description: "Process viewer".into(),
|
||||
icon: "htop".into(),
|
||||
provider: "cmd".into(),
|
||||
score: 50,
|
||||
command: Some("htop".into()),
|
||||
terminal: true,
|
||||
tags: vec![],
|
||||
};
|
||||
let json = serde_json::to_string(&item).unwrap();
|
||||
assert!(json.contains("\"terminal\":true"));
|
||||
let parsed: ResultItem = serde_json::from_str(&json).unwrap();
|
||||
assert!(parsed.terminal);
|
||||
}
|
||||
239
crates/owlry-core/tests/server_test.rs
Normal file
@@ -0,0 +1,239 @@
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::thread;
|
||||
|
||||
use owlry_core::ipc::{Request, Response};
|
||||
use owlry_core::server::Server;
|
||||
|
||||
/// Helper: send a JSON request line and read the JSON response line.
|
||||
fn roundtrip(stream: &mut UnixStream, request: &Request) -> Response {
|
||||
let mut line = serde_json::to_string(request).unwrap();
|
||||
line.push('\n');
|
||||
stream.write_all(line.as_bytes()).unwrap();
|
||||
stream.flush().unwrap();
|
||||
|
||||
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
||||
let mut buf = String::new();
|
||||
reader.read_line(&mut buf).unwrap();
|
||||
serde_json::from_str(buf.trim()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_server_responds_to_providers_request() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sock = dir.path().join("owlry-test.sock");
|
||||
|
||||
let server = Server::bind(&sock).unwrap();
|
||||
|
||||
// Spawn the server to handle exactly one connection
|
||||
let handle = thread::spawn(move || {
|
||||
server.handle_one_for_testing().unwrap();
|
||||
});
|
||||
|
||||
// Connect as a client
|
||||
let mut stream = UnixStream::connect(&sock).unwrap();
|
||||
let resp = roundtrip(&mut stream, &Request::Providers);
|
||||
|
||||
match resp {
|
||||
Response::Providers { list } => {
|
||||
// The default ProviderManager always has at least Application and Command
|
||||
assert!(
|
||||
list.len() >= 2,
|
||||
"expected at least 2 providers, got {}",
|
||||
list.len()
|
||||
);
|
||||
let ids: Vec<&str> = list.iter().map(|p| p.id.as_str()).collect();
|
||||
assert!(ids.contains(&"app"), "missing 'app' provider");
|
||||
assert!(ids.contains(&"cmd"), "missing 'cmd' provider");
|
||||
}
|
||||
other => panic!("expected Providers response, got: {:?}", other),
|
||||
}
|
||||
|
||||
drop(stream);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_server_handles_launch_request() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sock = dir.path().join("owlry-test.sock");
|
||||
|
||||
let server = Server::bind(&sock).unwrap();
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
server.handle_one_for_testing().unwrap();
|
||||
});
|
||||
|
||||
let mut stream = UnixStream::connect(&sock).unwrap();
|
||||
let req = Request::Launch {
|
||||
item_id: "firefox.desktop".into(),
|
||||
provider: "app".into(),
|
||||
};
|
||||
let resp = roundtrip(&mut stream, &req);
|
||||
|
||||
assert_eq!(resp, Response::Ack);
|
||||
|
||||
drop(stream);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_server_handles_query_request() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sock = dir.path().join("owlry-test.sock");
|
||||
|
||||
let server = Server::bind(&sock).unwrap();
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
server.handle_one_for_testing().unwrap();
|
||||
});
|
||||
|
||||
let mut stream = UnixStream::connect(&sock).unwrap();
|
||||
let req = Request::Query {
|
||||
text: "nonexistent_query_xyz".into(),
|
||||
modes: None,
|
||||
};
|
||||
let resp = roundtrip(&mut stream, &req);
|
||||
|
||||
match resp {
|
||||
Response::Results { items } => {
|
||||
// A nonsense query should return empty or very few results
|
||||
// (no items will fuzzy-match "nonexistent_query_xyz")
|
||||
assert!(
|
||||
items.len() <= 5,
|
||||
"expected few/no results for gibberish query"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Results response, got: {:?}", other),
|
||||
}
|
||||
|
||||
drop(stream);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_server_handles_toggle_request() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sock = dir.path().join("owlry-test.sock");
|
||||
|
||||
let server = Server::bind(&sock).unwrap();
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
server.handle_one_for_testing().unwrap();
|
||||
});
|
||||
|
||||
let mut stream = UnixStream::connect(&sock).unwrap();
|
||||
let resp = roundtrip(&mut stream, &Request::Toggle);
|
||||
|
||||
assert_eq!(resp, Response::Ack);
|
||||
|
||||
drop(stream);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_server_handles_refresh_request() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sock = dir.path().join("owlry-test.sock");
|
||||
|
||||
let server = Server::bind(&sock).unwrap();
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
server.handle_one_for_testing().unwrap();
|
||||
});
|
||||
|
||||
let mut stream = UnixStream::connect(&sock).unwrap();
|
||||
let req = Request::Refresh {
|
||||
provider: "app".into(),
|
||||
};
|
||||
let resp = roundtrip(&mut stream, &req);
|
||||
|
||||
assert_eq!(resp, Response::Ack);
|
||||
|
||||
drop(stream);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_server_handles_submenu_for_unknown_plugin() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sock = dir.path().join("owlry-test.sock");
|
||||
|
||||
let server = Server::bind(&sock).unwrap();
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
server.handle_one_for_testing().unwrap();
|
||||
});
|
||||
|
||||
let mut stream = UnixStream::connect(&sock).unwrap();
|
||||
let req = Request::Submenu {
|
||||
plugin_id: "nonexistent_plugin".into(),
|
||||
data: "some_data".into(),
|
||||
};
|
||||
let resp = roundtrip(&mut stream, &req);
|
||||
|
||||
match resp {
|
||||
Response::Error { message } => {
|
||||
assert!(
|
||||
message.contains("nonexistent_plugin"),
|
||||
"error should mention the plugin id"
|
||||
);
|
||||
}
|
||||
other => panic!(
|
||||
"expected Error response for unknown plugin, got: {:?}",
|
||||
other
|
||||
),
|
||||
}
|
||||
|
||||
drop(stream);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_server_handles_multiple_requests_per_connection() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sock = dir.path().join("owlry-test.sock");
|
||||
|
||||
let server = Server::bind(&sock).unwrap();
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
server.handle_one_for_testing().unwrap();
|
||||
});
|
||||
|
||||
let mut stream = UnixStream::connect(&sock).unwrap();
|
||||
|
||||
// Send Providers request
|
||||
let resp1 = roundtrip(&mut stream, &Request::Providers);
|
||||
assert!(matches!(resp1, Response::Providers { .. }));
|
||||
|
||||
// Send Toggle request on same connection
|
||||
let resp2 = roundtrip(&mut stream, &Request::Toggle);
|
||||
assert_eq!(resp2, Response::Ack);
|
||||
|
||||
drop(stream);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_server_cleans_up_stale_socket() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sock = dir.path().join("owlry-test.sock");
|
||||
|
||||
// Create a stale socket file
|
||||
std::os::unix::net::UnixListener::bind(&sock).unwrap();
|
||||
assert!(sock.exists());
|
||||
|
||||
// Server::bind should succeed by removing the stale socket
|
||||
let server = Server::bind(&sock).unwrap();
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
server.handle_one_for_testing().unwrap();
|
||||
});
|
||||
|
||||
let mut stream = UnixStream::connect(&sock).unwrap();
|
||||
let resp = roundtrip(&mut stream, &Request::Toggle);
|
||||
assert_eq!(resp, Response::Ack);
|
||||
|
||||
drop(stream);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "owlry-lua"
|
||||
version = "0.4.3"
|
||||
version = "1.0.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
@@ -20,7 +20,7 @@ owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
abi_stable = "0.11"
|
||||
|
||||
# Lua runtime
|
||||
mlua = { version = "0.10", features = ["lua54", "vendored", "send", "serialize"] }
|
||||
mlua = { version = "0.11", features = ["lua54", "vendored", "send", "serialize"] }
|
||||
|
||||
# Plugin manifest parsing
|
||||
toml = "0.8"
|
||||
@@ -31,7 +31,7 @@ serde_json = "1.0"
|
||||
semver = "1"
|
||||
|
||||
# HTTP client for plugins
|
||||
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
||||
reqwest = { version = "0.13", features = ["blocking", "json"] }
|
||||
|
||||
# Math expression evaluation
|
||||
meval = "0.2"
|
||||
|
||||
@@ -24,11 +24,14 @@ pub fn register_provider_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
/// 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")?
|
||||
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")?
|
||||
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")?
|
||||
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")?;
|
||||
|
||||
@@ -116,13 +119,14 @@ fn call_provider_function(
|
||||
// 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);
|
||||
}
|
||||
&& 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
|
||||
@@ -153,7 +157,9 @@ fn parse_item(table: &Table) -> LuaResult<PluginItem> {
|
||||
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 tags: Vec<String> = table
|
||||
.get::<Option<Vec<String>>>("tags")?
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut item = PluginItem::new(id, name, command);
|
||||
|
||||
@@ -176,7 +182,7 @@ fn parse_item(table: &Table) -> LuaResult<PluginItem> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::runtime::{create_lua_runtime, SandboxConfig};
|
||||
use crate::runtime::{SandboxConfig, create_lua_runtime};
|
||||
|
||||
#[test]
|
||||
fn test_register_static_provider() {
|
||||
|
||||
@@ -11,25 +11,37 @@ use std::path::{Path, PathBuf};
|
||||
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(
|
||||
"debug",
|
||||
lua.create_function(|_, msg: String| {
|
||||
eprintln!("[DEBUG] {}", msg);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
log.set("info", lua.create_function(|_, msg: String| {
|
||||
eprintln!("[INFO] {}", 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(
|
||||
"warn",
|
||||
lua.create_function(|_, msg: String| {
|
||||
eprintln!("[WARN] {}", msg);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
log.set("error", lua.create_function(|_, msg: String| {
|
||||
eprintln!("[ERROR] {}", msg);
|
||||
Ok(())
|
||||
})?)?;
|
||||
log.set(
|
||||
"error",
|
||||
lua.create_function(|_, msg: String| {
|
||||
eprintln!("[ERROR] {}", msg);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
owlry.set("log", log)?;
|
||||
Ok(())
|
||||
@@ -44,59 +56,79 @@ pub fn register_path_api(lua: &Lua, owlry: &Table, plugin_dir: &Path) -> LuaResu
|
||||
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())
|
||||
})?)?;
|
||||
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())
|
||||
})?)?;
|
||||
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())
|
||||
})?)?;
|
||||
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())
|
||||
})?)?;
|
||||
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())
|
||||
})?)?;
|
||||
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())
|
||||
})?)?;
|
||||
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() {
|
||||
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)
|
||||
})?)?;
|
||||
Ok(path)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
owlry.set("path", path)?;
|
||||
Ok(())
|
||||
@@ -111,76 +143,95 @@ pub fn register_fs_api(lua: &Lua, owlry: &Table, _plugin_dir: &Path) -> LuaResul
|
||||
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())
|
||||
})?)?;
|
||||
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())
|
||||
})?)?;
|
||||
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),
|
||||
}
|
||||
})?)?;
|
||||
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)?))
|
||||
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),
|
||||
}
|
||||
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)?))
|
||||
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),
|
||||
}
|
||||
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) {
|
||||
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),
|
||||
}
|
||||
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())
|
||||
})?)?;
|
||||
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(())
|
||||
@@ -195,18 +246,24 @@ 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()))
|
||||
})?)?;
|
||||
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),
|
||||
}
|
||||
})?)?;
|
||||
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(())
|
||||
@@ -219,9 +276,10 @@ pub fn register_json_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
/// 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();
|
||||
}
|
||||
&& let Some(home) = dirs::home_dir()
|
||||
{
|
||||
return home.join(&path[2..]).to_string_lossy().to_string();
|
||||
}
|
||||
path.to_string()
|
||||
}
|
||||
|
||||
@@ -305,7 +363,7 @@ fn lua_to_json(_lua: &Lua, value: &Value) -> LuaResult<serde_json::Value> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::runtime::{create_lua_runtime, SandboxConfig};
|
||||
use crate::runtime::{SandboxConfig, create_lua_runtime};
|
||||
|
||||
#[test]
|
||||
fn test_log_api() {
|
||||
@@ -316,7 +374,10 @@ mod tests {
|
||||
lua.globals().set("owlry", owlry).unwrap();
|
||||
|
||||
// Just verify it doesn't panic
|
||||
lua.load("owlry.log.info('test message')").set_name("test").call::<()>(()).unwrap();
|
||||
lua.load("owlry.log.info('test message')")
|
||||
.set_name("test")
|
||||
.call::<()>(())
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -327,10 +388,18 @@ mod tests {
|
||||
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();
|
||||
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();
|
||||
let plugin_dir: String = lua
|
||||
.load("return owlry.path.plugin_dir()")
|
||||
.set_name("test")
|
||||
.call(())
|
||||
.unwrap();
|
||||
assert_eq!(plugin_dir, "/tmp/test-plugin");
|
||||
}
|
||||
|
||||
@@ -342,10 +411,18 @@ mod tests {
|
||||
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();
|
||||
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();
|
||||
let is_dir: bool = lua
|
||||
.load("return owlry.fs.is_dir('/tmp')")
|
||||
.set_name("test")
|
||||
.call(())
|
||||
.unwrap();
|
||||
assert!(is_dir);
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,11 @@ pub struct LuaRuntimeVTable {
|
||||
/// 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>,
|
||||
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),
|
||||
}
|
||||
@@ -83,11 +87,15 @@ impl RuntimeHandle {
|
||||
/// Create a null handle (reserved for error cases)
|
||||
#[allow(dead_code)]
|
||||
fn null() -> Self {
|
||||
Self { ptr: std::ptr::null_mut() }
|
||||
Self {
|
||||
ptr: std::ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
|
||||
fn from_box<T>(state: Box<T>) -> Self {
|
||||
Self { ptr: Box::into_raw(state) as *mut () }
|
||||
Self {
|
||||
ptr: Box::into_raw(state) as *mut (),
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn drop_as<T>(&self) {
|
||||
@@ -147,7 +155,10 @@ impl LuaRuntimeState {
|
||||
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);
|
||||
eprintln!(
|
||||
"owlry-lua: Plugin '{}' not compatible with owlry {}",
|
||||
id, owlry_version
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -285,13 +296,19 @@ extern "C" fn runtime_refresh(handle: RuntimeHandle, provider_id: RStr<'_>) -> R
|
||||
state.refresh_provider(provider_id.as_str()).into()
|
||||
}
|
||||
|
||||
extern "C" fn runtime_query(handle: RuntimeHandle, provider_id: RStr<'_>, query: RStr<'_>) -> RVec<PluginItem> {
|
||||
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()
|
||||
state
|
||||
.query_provider(provider_id.as_str(), query.as_str())
|
||||
.into()
|
||||
}
|
||||
|
||||
extern "C" fn runtime_drop(handle: RuntimeHandle) {
|
||||
|
||||
@@ -8,7 +8,7 @@ use owlry_plugin_api::PluginItem;
|
||||
|
||||
use crate::api;
|
||||
use crate::manifest::PluginManifest;
|
||||
use crate::runtime::{create_lua_runtime, load_file, SandboxConfig};
|
||||
use crate::runtime::{SandboxConfig, create_lua_runtime, load_file};
|
||||
|
||||
/// Provider registration info from Lua
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -77,11 +77,13 @@ impl LoadedPlugin {
|
||||
// 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));
|
||||
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))?;
|
||||
load_file(&lua, &entry_path).map_err(|e| format!("Failed to load entry point: {}", e))?;
|
||||
|
||||
self.lua = Some(lua);
|
||||
Ok(())
|
||||
@@ -89,7 +91,9 @@ impl LoadedPlugin {
|
||||
|
||||
/// Get provider registrations from this plugin
|
||||
pub fn get_provider_registrations(&self) -> Result<Vec<ProviderRegistration>, String> {
|
||||
let lua = self.lua.as_ref()
|
||||
let lua = self
|
||||
.lua
|
||||
.as_ref()
|
||||
.ok_or_else(|| "Plugin not initialized".to_string())?;
|
||||
|
||||
api::get_provider_registrations(lua)
|
||||
@@ -98,25 +102,33 @@ impl LoadedPlugin {
|
||||
|
||||
/// 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()
|
||||
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))
|
||||
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()
|
||||
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))
|
||||
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> {
|
||||
pub fn discover_plugins(
|
||||
plugins_dir: &Path,
|
||||
) -> Result<HashMap<String, (PluginManifest, PathBuf)>, String> {
|
||||
let mut plugins = HashMap::new();
|
||||
|
||||
if !plugins_dir.exists() {
|
||||
@@ -146,13 +158,21 @@ pub fn discover_plugins(plugins_dir: &Path) -> Result<HashMap<String, (PluginMan
|
||||
Ok(manifest) => {
|
||||
let id = manifest.plugin.id.clone();
|
||||
if plugins.contains_key(&id) {
|
||||
eprintln!("owlry-lua: Duplicate plugin ID '{}', skipping {}", id, path.display());
|
||||
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);
|
||||
eprintln!(
|
||||
"owlry-lua: Failed to load plugin at {}: {}",
|
||||
path.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,10 +90,10 @@ pub struct PluginPermissions {
|
||||
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))?;
|
||||
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)
|
||||
}
|
||||
@@ -105,7 +105,12 @@ impl PluginManifest {
|
||||
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 == '-') {
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -116,7 +121,10 @@ impl PluginManifest {
|
||||
|
||||
// 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));
|
||||
return Err(format!(
|
||||
"Invalid owlry_version constraint: {}",
|
||||
self.plugin.owlry_version
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -28,7 +28,7 @@ impl Default for SandboxConfig {
|
||||
allow_commands: false,
|
||||
allow_network: false,
|
||||
allow_external_fs: false,
|
||||
max_run_time_ms: 5000, // 5 seconds
|
||||
max_run_time_ms: 5000, // 5 seconds
|
||||
max_memory: 64 * 1024 * 1024, // 64 MB
|
||||
}
|
||||
}
|
||||
@@ -50,11 +50,7 @@ impl SandboxConfig {
|
||||
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 libs = StdLib::COROUTINE | StdLib::TABLE | StdLib::STRING | StdLib::UTF8 | StdLib::MATH;
|
||||
|
||||
let lua = Lua::new_with(libs, mlua::LuaOptions::default())?;
|
||||
|
||||
@@ -74,11 +70,15 @@ fn setup_safe_globals(lua: &Lua) -> LuaResult<()> {
|
||||
|
||||
// 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(
|
||||
"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(
|
||||
"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)?;
|
||||
|
||||
@@ -107,8 +107,7 @@ fn os_time(_lua: &Lua, _args: ()) -> LuaResult<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)?;
|
||||
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()?
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "owlry-plugin-api"
|
||||
version = "0.4.3"
|
||||
version = "1.0.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -31,7 +31,9 @@ use abi_stable::StableAbi;
|
||||
pub use abi_stable::std_types::{ROption, RStr, RString, RVec};
|
||||
|
||||
/// Current plugin API version - plugins must match this
|
||||
pub const API_VERSION: u32 = 1;
|
||||
/// 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)]
|
||||
@@ -65,6 +67,14 @@ pub struct ProviderInfo {
|
||||
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
|
||||
@@ -77,6 +87,20 @@ pub enum ProviderKind {
|
||||
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)]
|
||||
@@ -260,12 +284,8 @@ pub enum NotifyUrgency {
|
||||
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,
|
||||
),
|
||||
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<'_>),
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
[package]
|
||||
name = "owlry-plugin-bookmarks"
|
||||
version = "0.4.3"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Bookmarks plugin for owlry - browser bookmark search"
|
||||
keywords = ["owlry", "plugin", "bookmarks", "browser"]
|
||||
categories = ["web-programming"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
|
||||
# For finding browser config directories
|
||||
dirs = "5.0"
|
||||
|
||||
# For parsing Chrome bookmarks JSON
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# For reading Firefox bookmarks (places.sqlite)
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||
@@ -1,686 +0,0 @@
|
||||
//! Bookmarks Plugin for Owlry
|
||||
//!
|
||||
//! A static provider that reads browser bookmarks from various browsers.
|
||||
//!
|
||||
//! Supported browsers:
|
||||
//! - Firefox (via places.sqlite using SQLx)
|
||||
//! - Chrome
|
||||
//! - Chromium
|
||||
//! - Brave
|
||||
//! - Edge
|
||||
|
||||
use abi_stable::std_types::{ROption, RStr, RString, RVec};
|
||||
use owlry_plugin_api::{
|
||||
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, API_VERSION,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
use sqlx::Row;
|
||||
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 (async via tokio)
|
||||
let rt = match tokio::runtime::Runtime::new() {
|
||||
Ok(rt) => rt,
|
||||
Err(_) => {
|
||||
loading.store(false, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
for path in Self::firefox_places_paths() {
|
||||
rt.block_on(async {
|
||||
Self::read_firefox_bookmarks_async(&path, &mut items).await;
|
||||
});
|
||||
}
|
||||
|
||||
// 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 asynchronously
|
||||
async fn read_firefox_bookmarks_async(places_path: &PathBuf, items: &mut Vec<PluginItem>) {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let temp_db = temp_dir.join("owlry_places_temp.sqlite");
|
||||
|
||||
if fs::copy(places_path, &temp_db).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 db_url = format!("sqlite:{}?mode=ro", temp_db.display());
|
||||
let favicons_url = if favicons_path.is_some() {
|
||||
Some(format!("sqlite:{}?mode=ro", temp_favicons.display()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let cache_dir = Self::ensure_favicon_cache_dir();
|
||||
|
||||
let bookmarks = Self::fetch_firefox_bookmarks_with_favicons(
|
||||
&db_url,
|
||||
favicons_url.as_deref(),
|
||||
cache_dir.as_ref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// 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 their favicons
|
||||
async fn fetch_firefox_bookmarks_with_favicons(
|
||||
places_url: &str,
|
||||
favicons_url: Option<&str>,
|
||||
cache_dir: Option<&PathBuf>,
|
||||
) -> Vec<(String, String, Option<String>)> {
|
||||
// First, fetch bookmarks from places.sqlite
|
||||
let pool = match SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect(places_url)
|
||||
.await
|
||||
{
|
||||
Ok(p) => p,
|
||||
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 rows = match sqlx::query(query).fetch_all(&pool).await {
|
||||
Ok(r) => r,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
let bookmarks: Vec<(String, String)> = rows
|
||||
.into_iter()
|
||||
.filter_map(|row| {
|
||||
let title: Option<String> = row.get("title");
|
||||
let url: Option<String> = row.get("url");
|
||||
match (title, url) {
|
||||
(Some(t), Some(u)) => Some((t, u)),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// If no favicons database or cache dir, return without favicons
|
||||
let (favicons_url, cache_dir) = match (favicons_url, cache_dir) {
|
||||
(Some(f), Some(c)) => (f, c),
|
||||
_ => return bookmarks.into_iter().map(|(t, u)| (t, u, None)).collect(),
|
||||
};
|
||||
|
||||
// Connect to favicons database
|
||||
let fav_pool = match SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect(favicons_url)
|
||||
.await
|
||||
{
|
||||
Ok(p) => p,
|
||||
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_pool, &url, cache_dir).await;
|
||||
results.push((title, url, favicon_path));
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
/// Get favicon for a URL, caching to file if needed
|
||||
async fn get_favicon_for_url(
|
||||
pool: &sqlx::SqlitePool,
|
||||
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 row = sqlx::query(query)
|
||||
.bind(page_url)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()??;
|
||||
|
||||
let data: Vec<u8> = row.get("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),
|
||||
}]
|
||||
.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)
|
||||
assert!(paths.len() >= 0);
|
||||
}
|
||||
|
||||
#[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 state = BookmarksState::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 state.items);
|
||||
assert_eq!(state.items.len(), 1);
|
||||
assert_eq!(state.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("'\\''"));
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
[package]
|
||||
name = "owlry-plugin-calculator"
|
||||
version = "0.4.3"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Calculator plugin for owlry - evaluates mathematical expressions"
|
||||
keywords = ["owlry", "plugin", "calculator"]
|
||||
categories = ["mathematics"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# Math expression evaluation
|
||||
meval = "0.2"
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
@@ -1,228 +0,0 @@
|
||||
//! 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, 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),
|
||||
}]
|
||||
.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());
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
[package]
|
||||
name = "owlry-plugin-clipboard"
|
||||
version = "0.4.3"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Clipboard plugin for owlry - clipboard history via cliphist"
|
||||
keywords = ["owlry", "plugin", "clipboard"]
|
||||
categories = ["os"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
@@ -1,256 +0,0 @@
|
||||
//! 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, 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),
|
||||
}]
|
||||
.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();
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
[package]
|
||||
name = "owlry-plugin-emoji"
|
||||
version = "0.4.3"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Emoji plugin for owlry - search and copy emojis"
|
||||
keywords = ["owlry", "plugin", "emoji"]
|
||||
categories = ["text-processing"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
@@ -1,562 +0,0 @@
|
||||
//! 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.
|
||||
|
||||
use abi_stable::std_types::{ROption, RStr, RString, RVec};
|
||||
use owlry_plugin_api::{
|
||||
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, 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 EmojiState {
|
||||
fn new() -> Self {
|
||||
Self { items: Vec::new() }
|
||||
}
|
||||
|
||||
fn load_emojis(&mut self) {
|
||||
self.items.clear();
|
||||
|
||||
// Common emojis with searchable names
|
||||
// Format: (emoji, name, keywords)
|
||||
let emojis: &[(&str, &str, &str)] = &[
|
||||
// Smileys & Emotion
|
||||
("😀", "grinning face", "smile happy"),
|
||||
("😃", "grinning face with big eyes", "smile happy"),
|
||||
("😄", "grinning face with smiling eyes", "smile happy laugh"),
|
||||
("😁", "beaming face with smiling eyes", "smile happy grin"),
|
||||
("😅", "grinning face with sweat", "smile nervous"),
|
||||
("🤣", "rolling on the floor laughing", "lol rofl funny"),
|
||||
("😂", "face with tears of joy", "laugh cry funny lol"),
|
||||
("🙂", "slightly smiling face", "smile"),
|
||||
("😊", "smiling face with smiling eyes", "blush happy"),
|
||||
("😇", "smiling face with halo", "angel innocent"),
|
||||
("🥰", "smiling face with hearts", "love adore"),
|
||||
("😍", "smiling face with heart-eyes", "love crush"),
|
||||
("🤩", "star-struck", "excited wow amazing"),
|
||||
("😘", "face blowing a kiss", "kiss love"),
|
||||
("😜", "winking face with tongue", "playful silly"),
|
||||
("🤪", "zany face", "crazy silly wild"),
|
||||
("😎", "smiling face with sunglasses", "cool"),
|
||||
("🤓", "nerd face", "geek glasses"),
|
||||
("🧐", "face with monocle", "thinking inspect"),
|
||||
("😏", "smirking face", "smug"),
|
||||
("😒", "unamused face", "meh annoyed"),
|
||||
("🙄", "face with rolling eyes", "whatever annoyed"),
|
||||
("😬", "grimacing face", "awkward nervous"),
|
||||
("😮💨", "face exhaling", "sigh relief"),
|
||||
("🤥", "lying face", "pinocchio lie"),
|
||||
("😌", "relieved face", "relaxed peaceful"),
|
||||
("😔", "pensive face", "sad thoughtful"),
|
||||
("😪", "sleepy face", "tired"),
|
||||
("🤤", "drooling face", "hungry yummy"),
|
||||
("😴", "sleeping face", "zzz tired"),
|
||||
("😷", "face with medical mask", "sick covid"),
|
||||
("🤒", "face with thermometer", "sick fever"),
|
||||
("🤕", "face with head-bandage", "hurt injured"),
|
||||
("🤢", "nauseated face", "sick gross"),
|
||||
("🤮", "face vomiting", "sick puke"),
|
||||
("🤧", "sneezing face", "achoo sick"),
|
||||
("🥵", "hot face", "sweating heat"),
|
||||
("🥶", "cold face", "freezing"),
|
||||
("😵", "face with crossed-out eyes", "dizzy dead"),
|
||||
("🤯", "exploding head", "mind blown wow"),
|
||||
("🤠", "cowboy hat face", "yeehaw western"),
|
||||
("🥳", "partying face", "celebration party"),
|
||||
("🥸", "disguised face", "incognito"),
|
||||
("🤡", "clown face", "circus"),
|
||||
("👻", "ghost", "halloween spooky"),
|
||||
("💀", "skull", "dead death"),
|
||||
("☠️", "skull and crossbones", "danger death"),
|
||||
("👽", "alien", "ufo extraterrestrial"),
|
||||
("🤖", "robot", "bot android"),
|
||||
("💩", "pile of poo", "poop"),
|
||||
("😈", "smiling face with horns", "devil evil"),
|
||||
("👿", "angry face with horns", "devil evil"),
|
||||
// Gestures & People
|
||||
("👋", "waving hand", "hello hi bye wave"),
|
||||
("🤚", "raised back of hand", "stop"),
|
||||
("🖐️", "hand with fingers splayed", "five high"),
|
||||
("✋", "raised hand", "stop high five"),
|
||||
("🖖", "vulcan salute", "spock trek"),
|
||||
("👌", "ok hand", "okay perfect"),
|
||||
("🤌", "pinched fingers", "italian"),
|
||||
("🤏", "pinching hand", "small tiny"),
|
||||
("✌️", "victory hand", "peace two"),
|
||||
("🤞", "crossed fingers", "luck hope"),
|
||||
("🤟", "love-you gesture", "ily rock"),
|
||||
("🤘", "sign of the horns", "rock metal"),
|
||||
("🤙", "call me hand", "shaka hang loose"),
|
||||
("👈", "backhand index pointing left", "left point"),
|
||||
("👉", "backhand index pointing right", "right point"),
|
||||
("👆", "backhand index pointing up", "up point"),
|
||||
("👇", "backhand index pointing down", "down point"),
|
||||
("☝️", "index pointing up", "one point"),
|
||||
("👍", "thumbs up", "like yes good approve"),
|
||||
("👎", "thumbs down", "dislike no bad"),
|
||||
("✊", "raised fist", "power solidarity"),
|
||||
("👊", "oncoming fist", "punch bump"),
|
||||
("🤛", "left-facing fist", "fist bump"),
|
||||
("🤜", "right-facing fist", "fist bump"),
|
||||
("👏", "clapping hands", "applause bravo"),
|
||||
("🙌", "raising hands", "hooray celebrate"),
|
||||
("👐", "open hands", "hug"),
|
||||
("🤲", "palms up together", "prayer"),
|
||||
("🤝", "handshake", "agreement deal"),
|
||||
("🙏", "folded hands", "prayer please thanks"),
|
||||
("✍️", "writing hand", "write"),
|
||||
("💪", "flexed biceps", "strong muscle"),
|
||||
("🦾", "mechanical arm", "robot prosthetic"),
|
||||
("🦵", "leg", "kick"),
|
||||
("🦶", "foot", "kick"),
|
||||
("👂", "ear", "listen hear"),
|
||||
("👃", "nose", "smell"),
|
||||
("🧠", "brain", "smart think"),
|
||||
("👀", "eyes", "look see watch"),
|
||||
("👁️", "eye", "see look"),
|
||||
("👅", "tongue", "taste lick"),
|
||||
("👄", "mouth", "lips kiss"),
|
||||
// Hearts & Love
|
||||
("❤️", "red heart", "love"),
|
||||
("🧡", "orange heart", "love"),
|
||||
("💛", "yellow heart", "love friendship"),
|
||||
("💚", "green heart", "love"),
|
||||
("💙", "blue heart", "love"),
|
||||
("💜", "purple heart", "love"),
|
||||
("🖤", "black heart", "love dark"),
|
||||
("🤍", "white heart", "love pure"),
|
||||
("🤎", "brown heart", "love"),
|
||||
("💔", "broken heart", "heartbreak sad"),
|
||||
("❤️🔥", "heart on fire", "passion love"),
|
||||
("❤️🩹", "mending heart", "healing recovery"),
|
||||
("💕", "two hearts", "love"),
|
||||
("💞", "revolving hearts", "love"),
|
||||
("💓", "beating heart", "love"),
|
||||
("💗", "growing heart", "love"),
|
||||
("💖", "sparkling heart", "love"),
|
||||
("💘", "heart with arrow", "love cupid"),
|
||||
("💝", "heart with ribbon", "love gift"),
|
||||
("💟", "heart decoration", "love"),
|
||||
// Animals
|
||||
("🐶", "dog face", "puppy"),
|
||||
("🐱", "cat face", "kitty"),
|
||||
("🐭", "mouse face", ""),
|
||||
("🐹", "hamster", ""),
|
||||
("🐰", "rabbit face", "bunny"),
|
||||
("🦊", "fox", ""),
|
||||
("🐻", "bear", ""),
|
||||
("🐼", "panda", ""),
|
||||
("🐨", "koala", ""),
|
||||
("🐯", "tiger face", ""),
|
||||
("🦁", "lion", ""),
|
||||
("🐮", "cow face", ""),
|
||||
("🐷", "pig face", ""),
|
||||
("🐸", "frog", ""),
|
||||
("🐵", "monkey face", ""),
|
||||
("🦄", "unicorn", "magic"),
|
||||
("🐝", "bee", "honeybee"),
|
||||
("🦋", "butterfly", ""),
|
||||
("🐌", "snail", "slow"),
|
||||
("🐛", "bug", "caterpillar"),
|
||||
("🦀", "crab", ""),
|
||||
("🐙", "octopus", ""),
|
||||
("🐠", "tropical fish", ""),
|
||||
("🐟", "fish", ""),
|
||||
("🐬", "dolphin", ""),
|
||||
("🐳", "whale", ""),
|
||||
("🦈", "shark", ""),
|
||||
("🐊", "crocodile", "alligator"),
|
||||
("🐢", "turtle", ""),
|
||||
("🦎", "lizard", ""),
|
||||
("🐍", "snake", ""),
|
||||
("🦖", "t-rex", "dinosaur"),
|
||||
("🦕", "sauropod", "dinosaur"),
|
||||
("🐔", "chicken", ""),
|
||||
("🐧", "penguin", ""),
|
||||
("🦅", "eagle", "bird"),
|
||||
("🦆", "duck", ""),
|
||||
("🦉", "owl", ""),
|
||||
// Food & Drink
|
||||
("🍎", "red apple", "fruit"),
|
||||
("🍐", "pear", "fruit"),
|
||||
("🍊", "orange", "tangerine fruit"),
|
||||
("🍋", "lemon", "fruit"),
|
||||
("🍌", "banana", "fruit"),
|
||||
("🍉", "watermelon", "fruit"),
|
||||
("🍇", "grapes", "fruit"),
|
||||
("🍓", "strawberry", "fruit"),
|
||||
("🍒", "cherries", "fruit"),
|
||||
("🍑", "peach", "fruit"),
|
||||
("🥭", "mango", "fruit"),
|
||||
("🍍", "pineapple", "fruit"),
|
||||
("🥥", "coconut", "fruit"),
|
||||
("🥝", "kiwi", "fruit"),
|
||||
("🍅", "tomato", "vegetable"),
|
||||
("🥑", "avocado", ""),
|
||||
("🥦", "broccoli", "vegetable"),
|
||||
("🥬", "leafy green", "vegetable salad"),
|
||||
("🥒", "cucumber", "vegetable"),
|
||||
("🌶️", "hot pepper", "spicy chili"),
|
||||
("🌽", "corn", ""),
|
||||
("🥕", "carrot", "vegetable"),
|
||||
("🧄", "garlic", ""),
|
||||
("🧅", "onion", ""),
|
||||
("🥔", "potato", ""),
|
||||
("🍞", "bread", ""),
|
||||
("🥐", "croissant", ""),
|
||||
("🥖", "baguette", "bread french"),
|
||||
("🥨", "pretzel", ""),
|
||||
("🧀", "cheese", ""),
|
||||
("🥚", "egg", ""),
|
||||
("🍳", "cooking", "frying pan egg"),
|
||||
("🥞", "pancakes", "breakfast"),
|
||||
("🧇", "waffle", "breakfast"),
|
||||
("🥓", "bacon", "breakfast"),
|
||||
("🍔", "hamburger", "burger"),
|
||||
("🍟", "french fries", ""),
|
||||
("🍕", "pizza", ""),
|
||||
("🌭", "hot dog", ""),
|
||||
("🥪", "sandwich", ""),
|
||||
("🌮", "taco", "mexican"),
|
||||
("🌯", "burrito", "mexican"),
|
||||
("🍜", "steaming bowl", "ramen noodles"),
|
||||
("🍝", "spaghetti", "pasta"),
|
||||
("🍣", "sushi", "japanese"),
|
||||
("🍱", "bento box", "japanese"),
|
||||
("🍩", "doughnut", "donut dessert"),
|
||||
("🍪", "cookie", "dessert"),
|
||||
("🎂", "birthday cake", "dessert"),
|
||||
("🍰", "shortcake", "dessert"),
|
||||
("🧁", "cupcake", "dessert"),
|
||||
("🍫", "chocolate bar", "dessert"),
|
||||
("🍬", "candy", "sweet"),
|
||||
("🍭", "lollipop", "candy sweet"),
|
||||
("🍦", "soft ice cream", "dessert"),
|
||||
("🍨", "ice cream", "dessert"),
|
||||
("☕", "hot beverage", "coffee tea"),
|
||||
("🍵", "teacup", "tea"),
|
||||
("🧃", "juice box", ""),
|
||||
("🥤", "cup with straw", "soda drink"),
|
||||
("🍺", "beer mug", "drink alcohol"),
|
||||
("🍻", "clinking beer mugs", "cheers drink"),
|
||||
("🥂", "clinking glasses", "champagne cheers"),
|
||||
("🍷", "wine glass", "drink alcohol"),
|
||||
("🥃", "tumbler glass", "whiskey drink"),
|
||||
("🍸", "cocktail glass", "martini drink"),
|
||||
// Objects & Symbols
|
||||
("💻", "laptop", "computer"),
|
||||
("🖥️", "desktop computer", "pc"),
|
||||
("⌨️", "keyboard", ""),
|
||||
("🖱️", "computer mouse", ""),
|
||||
("💾", "floppy disk", "save"),
|
||||
("💿", "optical disk", "cd"),
|
||||
("📱", "mobile phone", "smartphone"),
|
||||
("☎️", "telephone", "phone"),
|
||||
("📧", "email", "mail"),
|
||||
("📨", "incoming envelope", "email"),
|
||||
("📩", "envelope with arrow", "email send"),
|
||||
("📝", "memo", "note write"),
|
||||
("📄", "page facing up", "document"),
|
||||
("📃", "page with curl", "document"),
|
||||
("📑", "bookmark tabs", ""),
|
||||
("📚", "books", "library read"),
|
||||
("📖", "open book", "read"),
|
||||
("🔗", "link", "chain url"),
|
||||
("📎", "paperclip", "attachment"),
|
||||
("🔒", "locked", "security"),
|
||||
("🔓", "unlocked", "security open"),
|
||||
("🔑", "key", "password"),
|
||||
("🔧", "wrench", "tool fix"),
|
||||
("🔨", "hammer", "tool"),
|
||||
("⚙️", "gear", "settings"),
|
||||
("🧲", "magnet", ""),
|
||||
("💡", "light bulb", "idea"),
|
||||
("🔦", "flashlight", ""),
|
||||
("🔋", "battery", "power"),
|
||||
("🔌", "electric plug", "power"),
|
||||
("💰", "money bag", ""),
|
||||
("💵", "dollar", "money cash"),
|
||||
("💳", "credit card", "payment"),
|
||||
("⏰", "alarm clock", "time"),
|
||||
("⏱️", "stopwatch", "timer"),
|
||||
("📅", "calendar", "date"),
|
||||
("📆", "tear-off calendar", "date"),
|
||||
("✅", "check mark", "done yes"),
|
||||
("❌", "cross mark", "no wrong delete"),
|
||||
("❓", "question mark", "help"),
|
||||
("❗", "exclamation mark", "important warning"),
|
||||
("⚠️", "warning", "caution alert"),
|
||||
("🚫", "prohibited", "no ban forbidden"),
|
||||
("⭕", "hollow circle", ""),
|
||||
("🔴", "red circle", ""),
|
||||
("🟠", "orange circle", ""),
|
||||
("🟡", "yellow circle", ""),
|
||||
("🟢", "green circle", ""),
|
||||
("🔵", "blue circle", ""),
|
||||
("🟣", "purple circle", ""),
|
||||
("⚫", "black circle", ""),
|
||||
("⚪", "white circle", ""),
|
||||
("🟤", "brown circle", ""),
|
||||
("⬛", "black square", ""),
|
||||
("⬜", "white square", ""),
|
||||
("🔶", "large orange diamond", ""),
|
||||
("🔷", "large blue diamond", ""),
|
||||
("⭐", "star", "favorite"),
|
||||
("🌟", "glowing star", "sparkle"),
|
||||
("✨", "sparkles", "magic shine"),
|
||||
("💫", "dizzy", "star"),
|
||||
("🔥", "fire", "hot lit"),
|
||||
("💧", "droplet", "water"),
|
||||
("🌊", "wave", "water ocean"),
|
||||
("🎵", "musical note", "music"),
|
||||
("🎶", "musical notes", "music"),
|
||||
("🎤", "microphone", "sing karaoke"),
|
||||
("🎧", "headphones", "music"),
|
||||
("🎮", "video game", "gaming controller"),
|
||||
("🕹️", "joystick", "gaming"),
|
||||
("🎯", "direct hit", "target bullseye"),
|
||||
("🏆", "trophy", "winner award"),
|
||||
("🥇", "1st place medal", "gold winner"),
|
||||
("🥈", "2nd place medal", "silver"),
|
||||
("🥉", "3rd place medal", "bronze"),
|
||||
("🎁", "wrapped gift", "present"),
|
||||
("🎈", "balloon", "party"),
|
||||
("🎉", "party popper", "celebration tada"),
|
||||
("🎊", "confetti ball", "celebration"),
|
||||
// Arrows & Misc
|
||||
("➡️", "right arrow", ""),
|
||||
("⬅️", "left arrow", ""),
|
||||
("⬆️", "up arrow", ""),
|
||||
("⬇️", "down arrow", ""),
|
||||
("↗️", "up-right arrow", ""),
|
||||
("↘️", "down-right arrow", ""),
|
||||
("↙️", "down-left arrow", ""),
|
||||
("↖️", "up-left arrow", ""),
|
||||
("↕️", "up-down arrow", ""),
|
||||
("↔️", "left-right arrow", ""),
|
||||
("🔄", "counterclockwise arrows", "refresh reload"),
|
||||
("🔃", "clockwise arrows", "refresh reload"),
|
||||
("➕", "plus", "add"),
|
||||
("➖", "minus", "subtract"),
|
||||
("➗", "division", "divide"),
|
||||
("✖️", "multiply", "times"),
|
||||
("♾️", "infinity", "forever"),
|
||||
("💯", "hundred points", "100 perfect"),
|
||||
("🆗", "ok button", "okay"),
|
||||
("🆕", "new button", ""),
|
||||
("🆓", "free button", ""),
|
||||
("ℹ️", "information", "info"),
|
||||
("🅿️", "parking", ""),
|
||||
("🚀", "rocket", "launch startup"),
|
||||
("✈️", "airplane", "travel flight"),
|
||||
("🚗", "car", "automobile"),
|
||||
("🚕", "taxi", "cab"),
|
||||
("🚌", "bus", ""),
|
||||
("🚂", "locomotive", "train"),
|
||||
("🏠", "house", "home"),
|
||||
("🏢", "office building", "work"),
|
||||
("🏥", "hospital", ""),
|
||||
("🏫", "school", ""),
|
||||
("🏛️", "classical building", ""),
|
||||
("⛪", "church", ""),
|
||||
("🕌", "mosque", ""),
|
||||
("🕍", "synagogue", ""),
|
||||
("🗽", "statue of liberty", "usa america"),
|
||||
("🗼", "tokyo tower", "japan"),
|
||||
("🗾", "map of japan", ""),
|
||||
("🌍", "globe europe-africa", "earth world"),
|
||||
("🌎", "globe americas", "earth world"),
|
||||
("🌏", "globe asia-australia", "earth world"),
|
||||
("🌑", "new moon", ""),
|
||||
("🌕", "full moon", ""),
|
||||
("☀️", "sun", "sunny"),
|
||||
("🌙", "crescent moon", "night"),
|
||||
("☁️", "cloud", ""),
|
||||
("🌧️", "cloud with rain", "rainy"),
|
||||
("⛈️", "cloud with lightning", "storm thunder"),
|
||||
("🌈", "rainbow", ""),
|
||||
("❄️", "snowflake", "cold winter"),
|
||||
("☃️", "snowman", "winter"),
|
||||
("🎄", "christmas tree", "xmas holiday"),
|
||||
("🎃", "jack-o-lantern", "halloween pumpkin"),
|
||||
("🐚", "shell", "beach"),
|
||||
("🌸", "cherry blossom", "flower spring"),
|
||||
("🌺", "hibiscus", "flower"),
|
||||
("🌻", "sunflower", "flower"),
|
||||
("🌹", "rose", "flower love"),
|
||||
("🌷", "tulip", "flower"),
|
||||
("🌱", "seedling", "plant grow"),
|
||||
("🌲", "evergreen tree", ""),
|
||||
("🌳", "deciduous tree", ""),
|
||||
("🌴", "palm tree", "tropical"),
|
||||
("🌵", "cactus", "desert"),
|
||||
("🍀", "four leaf clover", "luck irish"),
|
||||
("🍁", "maple leaf", "fall autumn canada"),
|
||||
("🍂", "fallen leaf", "fall autumn"),
|
||||
];
|
||||
|
||||
for (emoji, name, keywords) in emojis {
|
||||
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()]),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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),
|
||||
}]
|
||||
.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_state_new() {
|
||||
let state = EmojiState::new();
|
||||
assert!(state.items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emoji_count() {
|
||||
let mut state = EmojiState::new();
|
||||
state.load_emojis();
|
||||
assert!(state.items.len() > 100, "Should have more than 100 emojis");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emoji_has_grinning_face() {
|
||||
let mut state = EmojiState::new();
|
||||
state.load_emojis();
|
||||
|
||||
let grinning = state
|
||||
.items
|
||||
.iter()
|
||||
.find(|i| i.name.as_str() == "grinning face");
|
||||
assert!(grinning.is_some());
|
||||
|
||||
let item = grinning.unwrap();
|
||||
assert!(item.description.as_ref().unwrap().as_str().contains("😀"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emoji_command_format() {
|
||||
let mut state = EmojiState::new();
|
||||
state.load_emojis();
|
||||
|
||||
let item = &state.items[0];
|
||||
assert!(item.command.as_str().contains("wl-copy"));
|
||||
assert!(item.command.as_str().contains("printf"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emojis_have_keywords() {
|
||||
let mut state = EmojiState::new();
|
||||
state.load_emojis();
|
||||
|
||||
// Check that items have keywords for searching
|
||||
let heart = state
|
||||
.items
|
||||
.iter()
|
||||
.find(|i| i.name.as_str() == "red heart");
|
||||
assert!(heart.is_some());
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
[package]
|
||||
name = "owlry-plugin-filesearch"
|
||||
version = "0.4.3"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "File search plugin for owlry - find files with fd or locate"
|
||||
keywords = ["owlry", "plugin", "files", "search"]
|
||||
categories = ["filesystem"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
|
||||
# For finding home directory
|
||||
dirs = "5.0"
|
||||
@@ -1,319 +0,0 @@
|
||||
//! 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, 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),
|
||||
}]
|
||||
.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());
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
[package]
|
||||
name = "owlry-plugin-media"
|
||||
version = "0.4.3"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "MPRIS media player widget plugin for owlry - shows and controls currently playing media. Requires playerctl."
|
||||
keywords = ["owlry", "plugin", "media", "mpris", "widget", "playerctl"]
|
||||
categories = ["gui"]
|
||||
|
||||
# System dependencies (for packagers):
|
||||
# - playerctl: for media control commands
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
@@ -1,465 +0,0 @@
|
||||
//! 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, 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),
|
||||
}]
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
[package]
|
||||
name = "owlry-plugin-pomodoro"
|
||||
version = "0.4.3"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Pomodoro timer widget plugin for owlry - work/break cycles with persistent state"
|
||||
keywords = ["owlry", "plugin", "pomodoro", "timer", "widget"]
|
||||
categories = ["gui"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
|
||||
# JSON serialization for persistent state
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# TOML config parsing
|
||||
toml = "0.8"
|
||||
|
||||
# For finding data directory
|
||||
dirs = "5.0"
|
||||
@@ -1,476 +0,0 @@
|
||||
//! 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, 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),
|
||||
}]
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
[package]
|
||||
name = "owlry-plugin-scripts"
|
||||
version = "0.4.3"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Scripts plugin for owlry - run user scripts from ~/.local/share/owlry/scripts/"
|
||||
keywords = ["owlry", "plugin", "scripts"]
|
||||
categories = ["os"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
|
||||
# For finding ~/.local/share/owlry/scripts
|
||||
dirs = "5.0"
|
||||
@@ -1,287 +0,0 @@
|
||||
//! 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, 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),
|
||||
}]
|
||||
.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"));
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
[package]
|
||||
name = "owlry-plugin-ssh"
|
||||
version = "0.4.3"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "SSH plugin for owlry - quick connect to SSH hosts from ~/.ssh/config"
|
||||
keywords = ["owlry", "plugin", "ssh"]
|
||||
categories = ["network-programming"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
|
||||
# For finding ~/.ssh/config
|
||||
dirs = "5.0"
|
||||
@@ -1,325 +0,0 @@
|
||||
//! 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, 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),
|
||||
}]
|
||||
.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"));
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
[package]
|
||||
name = "owlry-plugin-system"
|
||||
version = "0.4.3"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "System plugin for owlry - power and session management commands"
|
||||
keywords = ["owlry", "plugin", "system", "power"]
|
||||
categories = ["os"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
@@ -1,251 +0,0 @@
|
||||
//! 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, 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),
|
||||
}]
|
||||
.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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
[package]
|
||||
name = "owlry-plugin-systemd"
|
||||
version = "0.4.3"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "systemd user services plugin for owlry - list and control user-level systemd services"
|
||||
keywords = ["owlry", "plugin", "systemd", "services"]
|
||||
categories = ["os"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
@@ -1,454 +0,0 @@
|
||||
//! 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, 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),
|
||||
}]
|
||||
.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");
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
[package]
|
||||
name = "owlry-plugin-weather"
|
||||
version = "0.4.3"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Weather widget plugin for owlry - shows current weather with multiple API support"
|
||||
keywords = ["owlry", "plugin", "weather", "widget"]
|
||||
categories = ["gui"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
|
||||
# HTTP client for weather API requests
|
||||
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
||||
|
||||
# JSON parsing for API responses
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# TOML config parsing
|
||||
toml = "0.8"
|
||||
|
||||
# XDG directories for cache persistence
|
||||
dirs = "5.0"
|
||||
@@ -1,751 +0,0 @@
|
||||
//! 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, API_VERSION,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
// Plugin metadata
|
||||
const PLUGIN_ID: &str = "weather";
|
||||
const PLUGIN_NAME: &str = "Weather";
|
||||
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const PLUGIN_DESCRIPTION: &str = "Weather widget with multiple API support";
|
||||
|
||||
// Provider metadata
|
||||
const PROVIDER_ID: &str = "weather";
|
||||
const PROVIDER_NAME: &str = "Weather";
|
||||
const PROVIDER_ICON: &str = "weather-clear";
|
||||
const PROVIDER_TYPE_ID: &str = "weather";
|
||||
|
||||
// Timing constants
|
||||
const CACHE_DURATION_SECS: u64 = 900; // 15 minutes
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const USER_AGENT: &str = "owlry-launcher/0.3";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum WeatherProviderType {
|
||||
WttrIn,
|
||||
OpenWeatherMap,
|
||||
OpenMeteo,
|
||||
}
|
||||
|
||||
impl std::str::FromStr for WeatherProviderType {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"wttr.in" | "wttr" | "wttrin" => Ok(Self::WttrIn),
|
||||
"openweathermap" | "owm" => Ok(Self::OpenWeatherMap),
|
||||
"open-meteo" | "openmeteo" | "meteo" => Ok(Self::OpenMeteo),
|
||||
_ => Err(format!("Unknown weather provider: {}", s)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct WeatherConfig {
|
||||
provider: WeatherProviderType,
|
||||
api_key: Option<String>,
|
||||
location: String,
|
||||
}
|
||||
|
||||
impl WeatherConfig {
|
||||
/// Load config from ~/.config/owlry/config.toml
|
||||
///
|
||||
/// Reads from [plugins.weather] section, with fallback to [providers] for compatibility.
|
||||
fn load() -> Self {
|
||||
let config_path = dirs::config_dir()
|
||||
.map(|d| d.join("owlry").join("config.toml"));
|
||||
|
||||
let config_content = config_path
|
||||
.and_then(|p| fs::read_to_string(p).ok());
|
||||
|
||||
if let Some(content) = config_content
|
||||
&& let Ok(toml) = content.parse::<toml::Table>()
|
||||
{
|
||||
// Try [plugins.weather] first (new format)
|
||||
if let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table())
|
||||
&& let Some(weather) = plugins.get("weather").and_then(|v| v.as_table())
|
||||
{
|
||||
return Self::from_toml_table(weather);
|
||||
}
|
||||
|
||||
// Fallback to [providers] section (old format)
|
||||
if let Some(providers) = toml.get("providers").and_then(|v| v.as_table()) {
|
||||
let provider_str = providers
|
||||
.get("weather_provider")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("wttr.in");
|
||||
|
||||
let provider = provider_str.parse().unwrap_or(WeatherProviderType::WttrIn);
|
||||
|
||||
let api_key = providers
|
||||
.get("weather_api_key")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let location = providers
|
||||
.get("weather_location")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
return Self {
|
||||
provider,
|
||||
api_key,
|
||||
location,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Default config
|
||||
Self {
|
||||
provider: WeatherProviderType::WttrIn,
|
||||
api_key: None,
|
||||
location: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse config from a TOML table
|
||||
fn from_toml_table(table: &toml::Table) -> Self {
|
||||
let provider_str = table
|
||||
.get("provider")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("wttr.in");
|
||||
|
||||
let provider = provider_str.parse().unwrap_or(WeatherProviderType::WttrIn);
|
||||
|
||||
let api_key = table
|
||||
.get("api_key")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let location = table
|
||||
.get("location")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Self {
|
||||
provider,
|
||||
api_key,
|
||||
location,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached weather data (persisted to disk)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct WeatherData {
|
||||
temperature: f32,
|
||||
feels_like: Option<f32>,
|
||||
condition: String,
|
||||
humidity: Option<u8>,
|
||||
wind_speed: Option<f32>,
|
||||
icon: String,
|
||||
location: String,
|
||||
}
|
||||
|
||||
/// Persistent cache structure (saved to ~/.local/share/owlry/weather_cache.json)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct WeatherCache {
|
||||
last_fetch_epoch: u64,
|
||||
data: WeatherData,
|
||||
}
|
||||
|
||||
/// Weather provider state
|
||||
struct WeatherState {
|
||||
items: Vec<PluginItem>,
|
||||
config: WeatherConfig,
|
||||
last_fetch_epoch: u64,
|
||||
cached_data: Option<WeatherData>,
|
||||
}
|
||||
|
||||
impl WeatherState {
|
||||
fn new() -> Self {
|
||||
Self::with_config(WeatherConfig::load())
|
||||
}
|
||||
|
||||
fn with_config(config: WeatherConfig) -> Self {
|
||||
// Load cached weather from disk if available
|
||||
// This prevents blocking HTTP requests on every app open
|
||||
let (last_fetch_epoch, cached_data) = Self::load_cache()
|
||||
.map(|c| (c.last_fetch_epoch, Some(c.data)))
|
||||
.unwrap_or((0, None));
|
||||
|
||||
Self {
|
||||
items: Vec::new(),
|
||||
config,
|
||||
last_fetch_epoch,
|
||||
cached_data,
|
||||
}
|
||||
}
|
||||
|
||||
fn data_dir() -> Option<PathBuf> {
|
||||
dirs::data_dir().map(|d| d.join("owlry"))
|
||||
}
|
||||
|
||||
fn cache_path() -> Option<PathBuf> {
|
||||
Self::data_dir().map(|d| d.join("weather_cache.json"))
|
||||
}
|
||||
|
||||
fn load_cache() -> Option<WeatherCache> {
|
||||
let path = Self::cache_path()?;
|
||||
let content = fs::read_to_string(&path).ok()?;
|
||||
serde_json::from_str(&content).ok()
|
||||
}
|
||||
|
||||
fn save_cache(&self) {
|
||||
if let (Some(data_dir), Some(cache_path), Some(data)) =
|
||||
(Self::data_dir(), Self::cache_path(), &self.cached_data)
|
||||
{
|
||||
if fs::create_dir_all(&data_dir).is_err() {
|
||||
return;
|
||||
}
|
||||
let cache = WeatherCache {
|
||||
last_fetch_epoch: self.last_fetch_epoch,
|
||||
data: data.clone(),
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string_pretty(&cache) {
|
||||
let _ = fs::write(&cache_path, json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn now_epoch() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn is_cache_valid(&self) -> bool {
|
||||
if self.last_fetch_epoch == 0 {
|
||||
return false;
|
||||
}
|
||||
let now = Self::now_epoch();
|
||||
now.saturating_sub(self.last_fetch_epoch) < CACHE_DURATION_SECS
|
||||
}
|
||||
|
||||
fn refresh(&mut self) {
|
||||
// Use cache if still valid (works across app restarts)
|
||||
if self.is_cache_valid()
|
||||
&& let Some(data) = self.cached_data.clone() {
|
||||
self.generate_items(&data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch new data from API
|
||||
if let Some(data) = self.fetch_weather() {
|
||||
self.cached_data = Some(data.clone());
|
||||
self.last_fetch_epoch = Self::now_epoch();
|
||||
self.save_cache(); // Persist to disk for next app open
|
||||
self.generate_items(&data);
|
||||
} else {
|
||||
// On fetch failure, try to use stale cache if available
|
||||
if let Some(data) = self.cached_data.clone() {
|
||||
self.generate_items(&data);
|
||||
} else {
|
||||
self.items.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_weather(&self) -> Option<WeatherData> {
|
||||
match self.config.provider {
|
||||
WeatherProviderType::WttrIn => self.fetch_wttr_in(),
|
||||
WeatherProviderType::OpenWeatherMap => self.fetch_openweathermap(),
|
||||
WeatherProviderType::OpenMeteo => self.fetch_open_meteo(),
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_wttr_in(&self) -> Option<WeatherData> {
|
||||
let location = if self.config.location.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
self.config.location.clone()
|
||||
};
|
||||
|
||||
let url = format!("https://wttr.in/{}?format=j1", location);
|
||||
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.user_agent(USER_AGENT)
|
||||
.build()
|
||||
.ok()?;
|
||||
|
||||
let response = client.get(&url).send().ok()?;
|
||||
let json: WttrInResponse = response.json().ok()?;
|
||||
|
||||
let current = json.current_condition.first()?;
|
||||
let nearest = json.nearest_area.first()?;
|
||||
|
||||
let location_name = nearest
|
||||
.area_name
|
||||
.first()
|
||||
.map(|a| a.value.clone())
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
|
||||
Some(WeatherData {
|
||||
temperature: current.temp_c.parse().unwrap_or(0.0),
|
||||
feels_like: current.feels_like_c.parse().ok(),
|
||||
condition: current
|
||||
.weather_desc
|
||||
.first()
|
||||
.map(|d| d.value.clone())
|
||||
.unwrap_or_else(|| "Unknown".to_string()),
|
||||
humidity: current.humidity.parse().ok(),
|
||||
wind_speed: current.windspeed_kmph.parse().ok(),
|
||||
icon: Self::wttr_code_to_icon(¤t.weather_code),
|
||||
location: location_name,
|
||||
})
|
||||
}
|
||||
|
||||
fn fetch_openweathermap(&self) -> Option<WeatherData> {
|
||||
let api_key = self.config.api_key.as_ref()?;
|
||||
if self.config.location.is_empty() {
|
||||
return None; // OWM requires a location
|
||||
}
|
||||
|
||||
let url = format!(
|
||||
"https://api.openweathermap.org/data/2.5/weather?q={}&appid={}&units=metric",
|
||||
self.config.location, api_key
|
||||
);
|
||||
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.build()
|
||||
.ok()?;
|
||||
|
||||
let response = client.get(&url).send().ok()?;
|
||||
let json: OpenWeatherMapResponse = response.json().ok()?;
|
||||
|
||||
let weather = json.weather.first()?;
|
||||
|
||||
Some(WeatherData {
|
||||
temperature: json.main.temp,
|
||||
feels_like: Some(json.main.feels_like),
|
||||
condition: weather.description.clone(),
|
||||
humidity: Some(json.main.humidity),
|
||||
wind_speed: Some(json.wind.speed * 3.6), // m/s to km/h
|
||||
icon: Self::owm_icon_to_freedesktop(&weather.icon),
|
||||
location: json.name,
|
||||
})
|
||||
}
|
||||
|
||||
fn fetch_open_meteo(&self) -> Option<WeatherData> {
|
||||
let (lat, lon, location_name) = self.get_coordinates()?;
|
||||
|
||||
let url = format!(
|
||||
"https://api.open-meteo.com/v1/forecast?latitude={}&longitude={}¤t=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m&timezone=auto",
|
||||
lat, lon
|
||||
);
|
||||
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.build()
|
||||
.ok()?;
|
||||
|
||||
let response = client.get(&url).send().ok()?;
|
||||
let json: OpenMeteoResponse = response.json().ok()?;
|
||||
|
||||
let current = json.current;
|
||||
|
||||
Some(WeatherData {
|
||||
temperature: current.temperature_2m,
|
||||
feels_like: None,
|
||||
condition: Self::wmo_code_to_description(current.weather_code),
|
||||
humidity: Some(current.relative_humidity_2m as u8),
|
||||
wind_speed: Some(current.wind_speed_10m),
|
||||
icon: Self::wmo_code_to_icon(current.weather_code),
|
||||
location: location_name,
|
||||
})
|
||||
}
|
||||
|
||||
fn get_coordinates(&self) -> Option<(f64, f64, String)> {
|
||||
let location = &self.config.location;
|
||||
|
||||
// Check if location is already coordinates (lat,lon)
|
||||
if location.contains(',') {
|
||||
let parts: Vec<&str> = location.split(',').collect();
|
||||
if parts.len() == 2
|
||||
&& let (Ok(lat), Ok(lon)) = (
|
||||
parts[0].trim().parse::<f64>(),
|
||||
parts[1].trim().parse::<f64>(),
|
||||
) {
|
||||
return Some((lat, lon, location.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// Use Open-Meteo geocoding API
|
||||
let url = format!(
|
||||
"https://geocoding-api.open-meteo.com/v1/search?name={}&count=1",
|
||||
location
|
||||
);
|
||||
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.build()
|
||||
.ok()?;
|
||||
|
||||
let response = client.get(&url).send().ok()?;
|
||||
let json: GeocodingResponse = response.json().ok()?;
|
||||
|
||||
let result = json.results?.into_iter().next()?;
|
||||
Some((result.latitude, result.longitude, result.name))
|
||||
}
|
||||
|
||||
fn wttr_code_to_icon(code: &str) -> String {
|
||||
match code {
|
||||
"113" => "weather-clear",
|
||||
"116" => "weather-few-clouds",
|
||||
"119" => "weather-overcast",
|
||||
"122" => "weather-overcast",
|
||||
"143" | "248" | "260" => "weather-fog",
|
||||
"176" | "263" | "266" | "293" | "296" | "299" | "302" | "305" | "308" => {
|
||||
"weather-showers"
|
||||
}
|
||||
"179" | "182" | "185" | "227" | "230" | "323" | "326" | "329" | "332" | "335"
|
||||
| "338" | "350" | "368" | "371" | "374" | "377" => "weather-snow",
|
||||
"200" | "386" | "389" | "392" | "395" => "weather-storm",
|
||||
_ => "weather-clear",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn owm_icon_to_freedesktop(icon: &str) -> String {
|
||||
match icon {
|
||||
"01d" | "01n" => "weather-clear",
|
||||
"02d" | "02n" => "weather-few-clouds",
|
||||
"03d" | "03n" | "04d" | "04n" => "weather-overcast",
|
||||
"09d" | "09n" | "10d" | "10n" => "weather-showers",
|
||||
"11d" | "11n" => "weather-storm",
|
||||
"13d" | "13n" => "weather-snow",
|
||||
"50d" | "50n" => "weather-fog",
|
||||
_ => "weather-clear",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn wmo_code_to_description(code: i32) -> String {
|
||||
match code {
|
||||
0 => "Clear sky",
|
||||
1 => "Mainly clear",
|
||||
2 => "Partly cloudy",
|
||||
3 => "Overcast",
|
||||
45 | 48 => "Foggy",
|
||||
51 | 53 | 55 => "Drizzle",
|
||||
61 | 63 | 65 => "Rain",
|
||||
66 | 67 => "Freezing rain",
|
||||
71 | 73 | 75 | 77 => "Snow",
|
||||
80..=82 => "Rain showers",
|
||||
85 | 86 => "Snow showers",
|
||||
95 | 96 | 99 => "Thunderstorm",
|
||||
_ => "Unknown",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn wmo_code_to_icon(code: i32) -> String {
|
||||
match code {
|
||||
0 | 1 => "weather-clear",
|
||||
2 => "weather-few-clouds",
|
||||
3 => "weather-overcast",
|
||||
45 | 48 => "weather-fog",
|
||||
51 | 53 | 55 | 61 | 63 | 65 | 80 | 81 | 82 => "weather-showers",
|
||||
66 | 67 | 71 | 73 | 75 | 77 | 85 | 86 => "weather-snow",
|
||||
95 | 96 | 99 => "weather-storm",
|
||||
_ => "weather-clear",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn icon_to_resource_path(icon: &str) -> String {
|
||||
let weather_icon = if icon.contains("clear") {
|
||||
"wi-day-sunny"
|
||||
} else if icon.contains("few-clouds") {
|
||||
"wi-day-cloudy"
|
||||
} else if icon.contains("overcast") || icon.contains("clouds") {
|
||||
"wi-cloudy"
|
||||
} else if icon.contains("fog") {
|
||||
"wi-fog"
|
||||
} else if icon.contains("showers") || icon.contains("rain") {
|
||||
"wi-rain"
|
||||
} else if icon.contains("snow") {
|
||||
"wi-snow"
|
||||
} else if icon.contains("storm") {
|
||||
"wi-thunderstorm"
|
||||
} else {
|
||||
"wi-thermometer"
|
||||
};
|
||||
format!("/org/owlry/launcher/icons/weather/{}.svg", weather_icon)
|
||||
}
|
||||
|
||||
fn generate_items(&mut self, data: &WeatherData) {
|
||||
self.items.clear();
|
||||
|
||||
let temp_str = format!("{}°C", data.temperature.round() as i32);
|
||||
let name = format!("{} {}", temp_str, data.condition);
|
||||
|
||||
let mut details = vec![data.location.clone()];
|
||||
if let Some(humidity) = data.humidity {
|
||||
details.push(format!("Humidity {}%", humidity));
|
||||
}
|
||||
if let Some(wind) = data.wind_speed {
|
||||
details.push(format!("Wind {} km/h", wind.round() as i32));
|
||||
}
|
||||
if let Some(feels) = data.feels_like
|
||||
&& (feels - data.temperature).abs() > 2.0 {
|
||||
details.push(format!("Feels like {}°C", feels.round() as i32));
|
||||
}
|
||||
|
||||
let encoded_location = data.location.replace(' ', "+");
|
||||
let command = format!("xdg-open 'https://wttr.in/{}'", encoded_location);
|
||||
|
||||
self.items.push(
|
||||
PluginItem::new("weather-current", name, command)
|
||||
.with_description(details.join(" | "))
|
||||
.with_icon(Self::icon_to_resource_path(&data.icon))
|
||||
.with_keywords(vec!["weather".to_string(), "widget".to_string()]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Response Types
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WttrInResponse {
|
||||
current_condition: Vec<WttrInCurrent>,
|
||||
nearest_area: Vec<WttrInArea>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WttrInCurrent {
|
||||
#[serde(rename = "temp_C")]
|
||||
temp_c: String,
|
||||
#[serde(rename = "FeelsLikeC")]
|
||||
feels_like_c: String,
|
||||
humidity: String,
|
||||
#[serde(rename = "weatherCode")]
|
||||
weather_code: String,
|
||||
#[serde(rename = "weatherDesc")]
|
||||
weather_desc: Vec<WttrInValue>,
|
||||
#[serde(rename = "windspeedKmph")]
|
||||
windspeed_kmph: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WttrInValue {
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WttrInArea {
|
||||
#[serde(rename = "areaName")]
|
||||
area_name: Vec<WttrInValue>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenWeatherMapResponse {
|
||||
main: OwmMain,
|
||||
weather: Vec<OwmWeather>,
|
||||
wind: OwmWind,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OwmMain {
|
||||
temp: f32,
|
||||
feels_like: f32,
|
||||
humidity: u8,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OwmWeather {
|
||||
description: String,
|
||||
icon: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OwmWind {
|
||||
speed: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenMeteoResponse {
|
||||
current: OpenMeteoCurrent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenMeteoCurrent {
|
||||
temperature_2m: f32,
|
||||
relative_humidity_2m: f32,
|
||||
weather_code: i32,
|
||||
wind_speed_10m: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GeocodingResponse {
|
||||
results: Option<Vec<GeocodingResult>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GeocodingResult {
|
||||
name: String,
|
||||
latitude: f64,
|
||||
longitude: f64,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Interface Implementation
|
||||
// ============================================================================
|
||||
|
||||
extern "C" fn plugin_info() -> PluginInfo {
|
||||
PluginInfo {
|
||||
id: RString::from(PLUGIN_ID),
|
||||
name: RString::from(PLUGIN_NAME),
|
||||
version: RString::from(PLUGIN_VERSION),
|
||||
description: RString::from(PLUGIN_DESCRIPTION),
|
||||
api_version: API_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
|
||||
vec![ProviderInfo {
|
||||
id: RString::from(PROVIDER_ID),
|
||||
name: RString::from(PROVIDER_NAME),
|
||||
prefix: ROption::RNone,
|
||||
icon: RString::from(PROVIDER_ICON),
|
||||
provider_type: ProviderKind::Static,
|
||||
type_id: RString::from(PROVIDER_TYPE_ID),
|
||||
}]
|
||||
.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());
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
[package]
|
||||
name = "owlry-plugin-websearch"
|
||||
version = "0.4.3"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Web search plugin for owlry - search the web with configurable search engines"
|
||||
keywords = ["owlry", "plugin", "websearch", "search"]
|
||||
categories = ["web-programming"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
@@ -1,296 +0,0 @@
|
||||
//! 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, 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),
|
||||
}]
|
||||
.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
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "owlry-rune"
|
||||
version = "0.4.3"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.90"
|
||||
description = "Rune scripting runtime for owlry plugins"
|
||||
@@ -22,7 +22,7 @@ log = "0.4"
|
||||
env_logger = "0.11"
|
||||
|
||||
# HTTP client for network API
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "blocking"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "blocking"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -75,7 +75,11 @@ pub struct RuneRuntimeVTable {
|
||||
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 query: extern "C" fn(
|
||||
handle: RuntimeHandle,
|
||||
provider_id: RStr<'_>,
|
||||
query: RStr<'_>,
|
||||
) -> RVec<PluginItem>,
|
||||
pub drop: extern "C" fn(handle: RuntimeHandle),
|
||||
}
|
||||
|
||||
@@ -94,7 +98,10 @@ 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());
|
||||
log::info!(
|
||||
"Initializing Rune runtime with plugins from: {}",
|
||||
plugins_dir.display()
|
||||
);
|
||||
|
||||
let mut state = RuntimeState {
|
||||
plugins: HashMap::new(),
|
||||
@@ -113,15 +120,20 @@ extern "C" fn runtime_init(plugins_dir: RStr<'_>) -> RuntimeHandle {
|
||||
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()
|
||||
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());
|
||||
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);
|
||||
|
||||
@@ -8,7 +8,7 @@ use rune::{Context, Unit};
|
||||
|
||||
use crate::api::{self, ProviderRegistration};
|
||||
use crate::manifest::PluginManifest;
|
||||
use crate::runtime::{compile_source, create_context, create_vm, SandboxConfig};
|
||||
use crate::runtime::{SandboxConfig, compile_source, create_context, create_vm};
|
||||
|
||||
use owlry_plugin_api::PluginItem;
|
||||
|
||||
@@ -29,8 +29,8 @@ 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 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() {
|
||||
@@ -45,15 +45,14 @@ impl LoadedPlugin {
|
||||
.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))?;
|
||||
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(());
|
||||
let _: () = rune::from_value(result).unwrap_or(());
|
||||
}
|
||||
Err(_) => {
|
||||
// No main function is okay
|
||||
@@ -111,7 +110,10 @@ pub fn discover_rune_plugins(plugins_dir: &Path) -> Result<HashMap<String, Loade
|
||||
let mut plugins = HashMap::new();
|
||||
|
||||
if !plugins_dir.exists() {
|
||||
log::debug!("Plugins directory does not exist: {}", plugins_dir.display());
|
||||
log::debug!(
|
||||
"Plugins directory does not exist: {}",
|
||||
plugins_dir.display()
|
||||
);
|
||||
return Ok(plugins);
|
||||
}
|
||||
|
||||
@@ -135,7 +137,11 @@ pub fn discover_rune_plugins(plugins_dir: &Path) -> Result<HashMap<String, Loade
|
||||
let manifest = match PluginManifest::load(&manifest_path) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to load manifest at {}: {}", manifest_path.display(), e);
|
||||
log::warn!(
|
||||
"Failed to load manifest at {}: {}",
|
||||
manifest_path.display(),
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -64,10 +64,10 @@ pub struct PluginPermissions {
|
||||
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))?;
|
||||
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)
|
||||
}
|
||||
@@ -78,7 +78,12 @@ impl PluginManifest {
|
||||
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 == '-') {
|
||||
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());
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ pub struct SandboxConfig {
|
||||
pub allowed_commands: Vec<String>,
|
||||
}
|
||||
|
||||
|
||||
impl SandboxConfig {
|
||||
/// Create sandbox config from plugin permissions
|
||||
pub fn from_permissions(permissions: &PluginPermissions) -> Self {
|
||||
@@ -59,12 +58,9 @@ pub fn create_context(sandbox: &SandboxConfig) -> Result<Context, rune::ContextE
|
||||
}
|
||||
|
||||
/// 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()))?;
|
||||
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()
|
||||
@@ -73,7 +69,10 @@ pub fn compile_source(
|
||||
|
||||
let mut sources = Sources::new();
|
||||
sources
|
||||
.insert(Source::new(source_name, &source_content).map_err(|e| CompileError::Compile(e.to_string()))?)
|
||||
.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();
|
||||
@@ -97,13 +96,11 @@ pub fn compile_source(
|
||||
}
|
||||
|
||||
/// Create a new Rune VM from compiled unit
|
||||
pub fn create_vm(
|
||||
context: &Context,
|
||||
unit: Arc<Unit>,
|
||||
) -> Result<Vm, CompileError> {
|
||||
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)))?
|
||||
context
|
||||
.runtime()
|
||||
.map_err(|e| CompileError::Compile(format!("Failed to get runtime: {}", e)))?,
|
||||
);
|
||||
Ok(Vm::new(runtime, unit))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "owlry"
|
||||
version = "0.4.3"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.90"
|
||||
description = "A lightweight, owl-themed application launcher for Wayland"
|
||||
@@ -11,8 +11,8 @@ keywords = ["launcher", "wayland", "gtk4", "linux"]
|
||||
categories = ["gui"]
|
||||
|
||||
[dependencies]
|
||||
# Shared plugin API
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
# Core backend library
|
||||
owlry-core = { path = "../owlry-core" }
|
||||
|
||||
# GTK4 for the UI
|
||||
gtk4 = { version = "0.10", features = ["v4_12"] }
|
||||
@@ -20,60 +20,32 @@ 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
|
||||
# Low-level syscalls for stdin detection (dmenu mode)
|
||||
libc = "0.2"
|
||||
|
||||
# Logging
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
|
||||
# Error handling
|
||||
thiserror = "2"
|
||||
|
||||
# Configuration
|
||||
# Configuration (needed for config types used in app.rs/theme.rs)
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
toml = "0.8"
|
||||
|
||||
# CLI argument parsing
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
||||
# Math expression evaluation (for Lua plugins)
|
||||
meval = { version = "0.2", optional = true }
|
||||
|
||||
# JSON serialization for data persistence
|
||||
# JSON serialization (needed by plugin commands in CLI)
|
||||
serde_json = "1"
|
||||
|
||||
# Date/time for frecency calculations
|
||||
# Date/time (needed by plugin commands in CLI)
|
||||
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 }
|
||||
# Directory utilities (needed by plugin commands)
|
||||
dirs = "5"
|
||||
|
||||
# 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
|
||||
# Semantic versioning (needed by plugin commands)
|
||||
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"
|
||||
@@ -81,7 +53,6 @@ glib-build-tools = "0.20"
|
||||
[features]
|
||||
default = []
|
||||
# Enable verbose debug logging (for development/testing builds)
|
||||
dev-logging = []
|
||||
dev-logging = ["owlry-core/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"]
|
||||
lua = ["owlry-core/lua"]
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
use crate::backend::SearchBackend;
|
||||
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::client::CoreClient;
|
||||
use crate::providers::DmenuProvider;
|
||||
use crate::theme;
|
||||
use crate::ui::MainWindow;
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{gio, Application, CssProvider};
|
||||
use gtk4::{Application, CssProvider, gio};
|
||||
use gtk4_layer_shell::{Edge, Layer, LayerShell};
|
||||
use log::{debug, info, warn};
|
||||
use owlry_core::config::Config;
|
||||
use owlry_core::data::FrecencyStore;
|
||||
use owlry_core::filter::ProviderFilter;
|
||||
use owlry_core::paths;
|
||||
use owlry_core::providers::{Provider, ProviderManager, ProviderType};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
const APP_ID: &str = "org.owlry.launcher";
|
||||
|
||||
@@ -39,7 +36,7 @@ impl OwlryApp {
|
||||
|
||||
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.
|
||||
// This prevents GTK from trying to parse --mode, --profile, etc.
|
||||
self.app.run_with_args(&[] as &[&str]).into()
|
||||
}
|
||||
|
||||
@@ -52,33 +49,69 @@ impl OwlryApp {
|
||||
|
||||
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());
|
||||
// Build backend based on mode
|
||||
let dmenu_mode = DmenuProvider::has_stdin_data();
|
||||
|
||||
// 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);
|
||||
let backend = if dmenu_mode {
|
||||
// dmenu mode: local ProviderManager, no daemon
|
||||
let mut dmenu = DmenuProvider::new();
|
||||
dmenu.enable();
|
||||
let core_providers: Vec<Box<dyn Provider>> = vec![Box::new(dmenu)];
|
||||
let provider_manager = ProviderManager::new(core_providers, Vec::new());
|
||||
let frecency = FrecencyStore::load_or_default();
|
||||
|
||||
// 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());
|
||||
}
|
||||
SearchBackend::Local {
|
||||
providers: Box::new(provider_manager),
|
||||
frecency,
|
||||
}
|
||||
} else {
|
||||
// Normal mode: connect to daemon via IPC
|
||||
match CoreClient::connect_or_start() {
|
||||
Ok(client) => {
|
||||
info!("Connected to owlry-core daemon");
|
||||
SearchBackend::Daemon(client)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Failed to connect to daemon ({}), falling back to local providers",
|
||||
e
|
||||
);
|
||||
Self::create_local_backend(&config.borrow())
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let providers = Rc::new(RefCell::new(provider_manager));
|
||||
let frecency = Rc::new(RefCell::new(FrecencyStore::load_or_default()));
|
||||
let backend = Rc::new(RefCell::new(backend));
|
||||
|
||||
// Create filter from CLI args and config
|
||||
let filter = ProviderFilter::new(
|
||||
args.mode.clone(),
|
||||
args.providers.clone(),
|
||||
&config.borrow().providers,
|
||||
);
|
||||
// Create filter from CLI args, profile, and config
|
||||
let resolved_modes = resolve_modes(args, &config.borrow());
|
||||
let filter = if let Some(modes) = resolved_modes {
|
||||
// CLI --mode or --profile specified explicit modes
|
||||
let provider_types: Vec<ProviderType> = modes
|
||||
.iter()
|
||||
.map(|s| ProviderFilter::mode_string_to_provider_type(s))
|
||||
.collect();
|
||||
if provider_types.len() == 1 {
|
||||
ProviderFilter::new(
|
||||
Some(provider_types[0].clone()),
|
||||
None,
|
||||
&config.borrow().providers,
|
||||
)
|
||||
} else {
|
||||
ProviderFilter::new(None, Some(provider_types), &config.borrow().providers)
|
||||
}
|
||||
} else {
|
||||
ProviderFilter::new(None, None, &config.borrow().providers)
|
||||
};
|
||||
let filter = Rc::new(RefCell::new(filter));
|
||||
|
||||
let window = MainWindow::new(app, config.clone(), providers.clone(), frecency.clone(), filter.clone(), args.prompt.clone());
|
||||
let window = MainWindow::new(
|
||||
app,
|
||||
config.clone(),
|
||||
backend.clone(),
|
||||
filter.clone(),
|
||||
args.prompt.clone(),
|
||||
);
|
||||
|
||||
// Set up layer shell for Wayland overlay behavior
|
||||
window.init_layer_shell();
|
||||
@@ -104,97 +137,47 @@ impl OwlryApp {
|
||||
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();
|
||||
/// Create a local backend as fallback when daemon is unavailable.
|
||||
/// Loads native plugins and creates providers locally.
|
||||
fn create_local_backend(config: &Config) -> SearchBackend {
|
||||
use owlry_core::plugins::native_loader::NativePluginLoader;
|
||||
use owlry_core::providers::native_provider::NativeProvider;
|
||||
use owlry_core::providers::{ApplicationProvider, CommandProvider};
|
||||
use std::sync::Arc;
|
||||
|
||||
// Set disabled plugins from config
|
||||
// Load native plugins
|
||||
let mut loader = NativePluginLoader::new();
|
||||
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();
|
||||
let native_providers: Vec<NativeProvider> = match loader.discover() {
|
||||
Ok(count) if count > 0 => {
|
||||
info!("Discovered {} native plugin(s) for local fallback", count);
|
||||
let plugins: Vec<Arc<owlry_core::plugins::native_loader::NativePlugin>> =
|
||||
loader.into_plugins();
|
||||
let mut providers = Vec::new();
|
||||
for plugin in plugins {
|
||||
for provider_info in &plugin.providers {
|
||||
let provider =
|
||||
NativeProvider::new(Arc::clone(&plugin), provider_info.clone());
|
||||
providers.push(provider);
|
||||
}
|
||||
}
|
||||
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;
|
||||
providers
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
|
||||
// Get owlry version from Cargo.toml at compile time
|
||||
let owlry_version = env!("CARGO_PKG_VERSION");
|
||||
let core_providers: Vec<Box<dyn Provider>> = vec![
|
||||
Box::new(ApplicationProvider::new()),
|
||||
Box::new(CommandProvider::new()),
|
||||
];
|
||||
|
||||
let mut plugin_manager = PluginManager::new(plugins_dir, owlry_version);
|
||||
let provider_manager = ProviderManager::new(core_providers, native_providers);
|
||||
let frecency = FrecencyStore::load_or_default();
|
||||
|
||||
// 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);
|
||||
SearchBackend::Local {
|
||||
providers: Box::new(provider_manager),
|
||||
frecency,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,16 +237,17 @@ impl OwlryApp {
|
||||
|
||||
// 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);
|
||||
}
|
||||
&& 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);
|
||||
@@ -277,3 +261,21 @@ impl OwlryApp {
|
||||
debug!("Injected config CSS variables");
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve which modes to enable based on CLI args and config profiles.
|
||||
///
|
||||
/// Priority: `--mode` > `--profile` > default (all providers from config).
|
||||
/// Returns `None` when no explicit mode selection was made.
|
||||
fn resolve_modes(args: &CliArgs, config: &Config) -> Option<Vec<String>> {
|
||||
if let Some(ref mode) = args.mode {
|
||||
return Some(vec![mode.to_string()]);
|
||||
}
|
||||
if let Some(ref profile_name) = args.profile {
|
||||
if let Some(profile) = config.profiles.get(profile_name) {
|
||||
return Some(profile.modes.clone());
|
||||
}
|
||||
eprintln!("Unknown profile: {}", profile_name);
|
||||
std::process::exit(1);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
266
crates/owlry/src/backend.rs
Normal file
@@ -0,0 +1,266 @@
|
||||
//! Abstraction over search backends for the UI.
|
||||
//!
|
||||
//! In normal mode, the UI talks to the owlry-core daemon via IPC.
|
||||
//! In dmenu mode, the UI uses a local ProviderManager directly (no daemon).
|
||||
|
||||
use crate::client::CoreClient;
|
||||
use log::warn;
|
||||
use owlry_core::config::Config;
|
||||
use owlry_core::data::FrecencyStore;
|
||||
use owlry_core::filter::ProviderFilter;
|
||||
use owlry_core::ipc::ResultItem;
|
||||
use owlry_core::providers::{LaunchItem, ProviderManager, ProviderType};
|
||||
|
||||
/// Backend for search operations. Wraps either an IPC client (daemon mode)
|
||||
/// or a local ProviderManager (dmenu mode).
|
||||
pub enum SearchBackend {
|
||||
/// IPC client connected to owlry-core daemon
|
||||
Daemon(CoreClient),
|
||||
/// Direct local provider manager (dmenu mode only)
|
||||
Local {
|
||||
providers: Box<ProviderManager>,
|
||||
frecency: FrecencyStore,
|
||||
},
|
||||
}
|
||||
|
||||
impl SearchBackend {
|
||||
/// Search for items matching the query.
|
||||
///
|
||||
/// In daemon mode, sends query over IPC. The modes list is derived from
|
||||
/// the ProviderFilter's enabled set.
|
||||
///
|
||||
/// In local mode, delegates to ProviderManager directly.
|
||||
pub fn search(
|
||||
&mut self,
|
||||
query: &str,
|
||||
max_results: usize,
|
||||
filter: &ProviderFilter,
|
||||
config: &Config,
|
||||
) -> Vec<LaunchItem> {
|
||||
match self {
|
||||
SearchBackend::Daemon(client) => {
|
||||
let modes: Vec<String> = filter
|
||||
.enabled_providers()
|
||||
.iter()
|
||||
.map(|p| p.to_string())
|
||||
.collect();
|
||||
|
||||
let modes_param = if modes.is_empty() { None } else { Some(modes) };
|
||||
|
||||
match client.query(query, modes_param) {
|
||||
Ok(items) => items.into_iter().map(result_to_launch_item).collect(),
|
||||
Err(e) => {
|
||||
warn!("IPC query failed: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
SearchBackend::Local {
|
||||
providers,
|
||||
frecency,
|
||||
} => {
|
||||
let frecency_weight = config.providers.frecency_weight;
|
||||
let use_frecency = config.providers.frecency;
|
||||
|
||||
if use_frecency {
|
||||
providers
|
||||
.search_with_frecency(
|
||||
query,
|
||||
max_results,
|
||||
filter,
|
||||
frecency,
|
||||
frecency_weight,
|
||||
None,
|
||||
)
|
||||
.into_iter()
|
||||
.map(|(item, _)| item)
|
||||
.collect()
|
||||
} else {
|
||||
providers
|
||||
.search_filtered(query, max_results, filter)
|
||||
.into_iter()
|
||||
.map(|(item, _)| item)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Search with tag filter support.
|
||||
pub fn search_with_tag(
|
||||
&mut self,
|
||||
query: &str,
|
||||
max_results: usize,
|
||||
filter: &ProviderFilter,
|
||||
config: &Config,
|
||||
tag_filter: Option<&str>,
|
||||
) -> Vec<LaunchItem> {
|
||||
match self {
|
||||
SearchBackend::Daemon(client) => {
|
||||
// Daemon doesn't support tag filtering in IPC yet — pass query as-is.
|
||||
// If there's a tag filter, prepend it so the daemon can handle it.
|
||||
let effective_query = if let Some(tag) = tag_filter {
|
||||
format!(":tag:{} {}", tag, query)
|
||||
} else {
|
||||
query.to_string()
|
||||
};
|
||||
|
||||
let modes: Vec<String> = filter
|
||||
.enabled_providers()
|
||||
.iter()
|
||||
.map(|p| p.to_string())
|
||||
.collect();
|
||||
|
||||
let modes_param = if modes.is_empty() { None } else { Some(modes) };
|
||||
|
||||
match client.query(&effective_query, modes_param) {
|
||||
Ok(items) => items.into_iter().map(result_to_launch_item).collect(),
|
||||
Err(e) => {
|
||||
warn!("IPC query failed: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
SearchBackend::Local {
|
||||
providers,
|
||||
frecency,
|
||||
} => {
|
||||
let frecency_weight = config.providers.frecency_weight;
|
||||
let use_frecency = config.providers.frecency;
|
||||
|
||||
if use_frecency {
|
||||
providers
|
||||
.search_with_frecency(
|
||||
query,
|
||||
max_results,
|
||||
filter,
|
||||
frecency,
|
||||
frecency_weight,
|
||||
tag_filter,
|
||||
)
|
||||
.into_iter()
|
||||
.map(|(item, _)| item)
|
||||
.collect()
|
||||
} else {
|
||||
providers
|
||||
.search_filtered(query, max_results, filter)
|
||||
.into_iter()
|
||||
.map(|(item, _)| item)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a plugin action command. Returns true if handled.
|
||||
pub fn execute_plugin_action(&mut self, command: &str) -> bool {
|
||||
match self {
|
||||
SearchBackend::Daemon(client) => match client.plugin_action(command) {
|
||||
Ok(handled) => handled,
|
||||
Err(e) => {
|
||||
warn!("IPC plugin_action failed: {}", e);
|
||||
false
|
||||
}
|
||||
},
|
||||
SearchBackend::Local { providers, .. } => providers.execute_plugin_action(command),
|
||||
}
|
||||
}
|
||||
|
||||
/// Query submenu actions for a plugin item.
|
||||
/// Returns (display_name, actions) if available.
|
||||
pub fn query_submenu_actions(
|
||||
&mut self,
|
||||
plugin_id: &str,
|
||||
data: &str,
|
||||
display_name: &str,
|
||||
) -> Option<(String, Vec<LaunchItem>)> {
|
||||
match self {
|
||||
SearchBackend::Daemon(client) => match client.submenu(plugin_id, data) {
|
||||
Ok(items) if !items.is_empty() => {
|
||||
let actions: Vec<LaunchItem> =
|
||||
items.into_iter().map(result_to_launch_item).collect();
|
||||
Some((display_name.to_string(), actions))
|
||||
}
|
||||
Ok(_) => None,
|
||||
Err(e) => {
|
||||
warn!("IPC submenu query failed: {}", e);
|
||||
None
|
||||
}
|
||||
},
|
||||
SearchBackend::Local { providers, .. } => {
|
||||
providers.query_submenu_actions(plugin_id, data, display_name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a launch event for frecency tracking.
|
||||
pub fn record_launch(&mut self, item_id: &str, provider: &str) {
|
||||
match self {
|
||||
SearchBackend::Daemon(client) => {
|
||||
if let Err(e) = client.launch(item_id, provider) {
|
||||
warn!("IPC launch notification failed: {}", e);
|
||||
}
|
||||
}
|
||||
SearchBackend::Local { frecency, .. } => {
|
||||
frecency.record_launch(item_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this backend is in dmenu mode.
|
||||
pub fn is_dmenu_mode(&self) -> bool {
|
||||
match self {
|
||||
SearchBackend::Daemon(_) => false,
|
||||
SearchBackend::Local { providers, .. } => providers.is_dmenu_mode(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh widget providers. No-op for daemon mode (daemon handles refresh).
|
||||
pub fn refresh_widgets(&mut self) {
|
||||
if let SearchBackend::Local { providers, .. } = self {
|
||||
providers.refresh_widgets();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get available provider type IDs from the daemon, or from local manager.
|
||||
#[allow(dead_code)]
|
||||
pub fn available_provider_ids(&mut self) -> Vec<String> {
|
||||
match self {
|
||||
SearchBackend::Daemon(client) => match client.providers() {
|
||||
Ok(descs) => descs.into_iter().map(|d| d.id).collect(),
|
||||
Err(e) => {
|
||||
warn!("IPC providers query failed: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
},
|
||||
SearchBackend::Local { providers, .. } => providers
|
||||
.available_providers()
|
||||
.into_iter()
|
||||
.map(|d| d.id)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert an IPC ResultItem to the internal LaunchItem type.
|
||||
fn result_to_launch_item(item: ResultItem) -> LaunchItem {
|
||||
let provider: ProviderType = item.provider.parse().unwrap_or(ProviderType::Application);
|
||||
LaunchItem {
|
||||
id: item.id,
|
||||
name: item.title,
|
||||
description: if item.description.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(item.description)
|
||||
},
|
||||
icon: if item.icon.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(item.icon)
|
||||
},
|
||||
provider,
|
||||
command: item.command.unwrap_or_default(),
|
||||
terminal: item.terminal,
|
||||
tags: item.tags,
|
||||
}
|
||||
}
|
||||
@@ -4,25 +4,70 @@
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
use crate::providers::ProviderType;
|
||||
use owlry_core::providers::ProviderType;
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[command(
|
||||
name = "owlry",
|
||||
about = "An owl-themed application launcher for Wayland",
|
||||
version
|
||||
long_about = "An owl-themed application launcher for Wayland, built with GTK4 and Layer Shell.\n\n\
|
||||
Owlry provides fuzzy search across applications, commands, and plugins.\n\
|
||||
Native plugins add features like calculator, clipboard, emoji, weather, and more.",
|
||||
version,
|
||||
after_help = "\
|
||||
EXAMPLES:
|
||||
owlry Launch with all providers
|
||||
owlry -m app Applications only
|
||||
owlry -m cmd PATH commands only
|
||||
owlry -m dmenu dmenu-compatible mode (reads from stdin)
|
||||
owlry --profile dev Use a named profile from config
|
||||
owlry -m calc Calculator plugin only (if installed)
|
||||
|
||||
DMENU MODE:
|
||||
Pipe input to owlry for interactive selection:
|
||||
|
||||
echo -e \"Option A\\nOption B\" | owlry -m dmenu
|
||||
ls | owlry -m dmenu -p \"checkout:\"
|
||||
git branch | owlry -m dmenu --prompt \"checkout:\"
|
||||
|
||||
PROFILES:
|
||||
Define profiles in ~/.config/owlry/config.toml:
|
||||
|
||||
[profiles.dev]
|
||||
modes = [\"app\", \"cmd\", \"ssh\"]
|
||||
|
||||
Then launch with: owlry --profile dev
|
||||
|
||||
SEARCH PREFIXES:
|
||||
:app firefox Search applications
|
||||
:cmd git Search PATH commands
|
||||
= 5+3 Calculator (requires plugin)
|
||||
? rust docs Web search (requires plugin)
|
||||
/ .bashrc File search (requires plugin)
|
||||
|
||||
For configuration, see ~/.config/owlry/config.toml
|
||||
For plugin management, see: owlry plugin --help"
|
||||
)]
|
||||
pub struct CliArgs {
|
||||
/// Start in single-provider mode (app, cmd, uuctl)
|
||||
#[arg(long, short = 'm', value_parser = parse_provider)]
|
||||
/// 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 (app,cmd,uuctl)
|
||||
#[arg(long, short = 'p', value_delimiter = ',', value_parser = parse_provider)]
|
||||
pub providers: Option<Vec<ProviderType>>,
|
||||
/// Use a named profile from config (defines which modes to enable)
|
||||
///
|
||||
/// Profiles are defined in config.toml under [profiles.<name>].
|
||||
/// Example: --profile dev (loads modes from [profiles.dev])
|
||||
#[arg(long, value_name = "NAME")]
|
||||
pub profile: Option<String>,
|
||||
|
||||
/// Custom prompt text for the search input (useful for dmenu mode)
|
||||
#[arg(long)]
|
||||
/// Custom prompt text for the search input
|
||||
///
|
||||
/// Useful in dmenu mode to indicate what the user is selecting.
|
||||
/// Example: -p "Select file:" or --prompt "Select file:"
|
||||
#[arg(long, short = 'p', value_name = "TEXT")]
|
||||
pub prompt: Option<String>,
|
||||
|
||||
/// Subcommand to run (if any)
|
||||
|
||||
367
crates/owlry/src/client.rs
Normal file
@@ -0,0 +1,367 @@
|
||||
use std::io::{self, BufRead, BufReader, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use owlry_core::ipc::{ProviderDesc, Request, Response, ResultItem};
|
||||
|
||||
/// IPC client that connects to the owlry-core daemon Unix socket
|
||||
/// and provides typed methods for all IPC operations.
|
||||
pub struct CoreClient {
|
||||
stream: UnixStream,
|
||||
reader: BufReader<UnixStream>,
|
||||
}
|
||||
|
||||
impl CoreClient {
|
||||
/// Connect to a running daemon at the given socket path.
|
||||
///
|
||||
/// Sets a 5-second read timeout so the client doesn't hang indefinitely
|
||||
/// if the daemon stops responding.
|
||||
pub fn connect(socket_path: &Path) -> io::Result<Self> {
|
||||
let stream = UnixStream::connect(socket_path)?;
|
||||
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
|
||||
let reader = BufReader::new(stream.try_clone()?);
|
||||
Ok(Self { stream, reader })
|
||||
}
|
||||
|
||||
/// Try connecting to the daemon. If the socket isn't available, attempt
|
||||
/// to start the daemon via systemd and retry with exponential backoff.
|
||||
///
|
||||
/// Backoff schedule: 100ms, 200ms, 400ms.
|
||||
pub fn connect_or_start() -> io::Result<Self> {
|
||||
let path = Self::socket_path();
|
||||
|
||||
// First attempt: just try connecting.
|
||||
if let Ok(client) = Self::connect(&path) {
|
||||
return Ok(client);
|
||||
}
|
||||
|
||||
// Socket not available — try to start the daemon.
|
||||
let status = std::process::Command::new("systemctl")
|
||||
.args(["--user", "start", "owlry-core"])
|
||||
.status()
|
||||
.map_err(|e| {
|
||||
io::Error::other(format!("failed to start owlry-core via systemd: {e}"))
|
||||
})?;
|
||||
|
||||
if !status.success() {
|
||||
return Err(io::Error::other(format!(
|
||||
"systemctl --user start owlry-core exited with status {}",
|
||||
status
|
||||
)));
|
||||
}
|
||||
|
||||
// Retry with exponential backoff.
|
||||
let delays = [100, 200, 400];
|
||||
for (i, ms) in delays.iter().enumerate() {
|
||||
std::thread::sleep(Duration::from_millis(*ms));
|
||||
match Self::connect(&path) {
|
||||
Ok(client) => return Ok(client),
|
||||
Err(e) if i == delays.len() - 1 => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::ConnectionRefused,
|
||||
format!("daemon started but socket not available after retries: {e}"),
|
||||
));
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
/// Default socket path: `$XDG_RUNTIME_DIR/owlry/owlry.sock`.
|
||||
///
|
||||
/// Delegates to `owlry_core::paths::socket_path()` to keep a single
|
||||
/// source of truth.
|
||||
pub fn socket_path() -> PathBuf {
|
||||
owlry_core::paths::socket_path()
|
||||
}
|
||||
|
||||
/// Send a search query and return matching results.
|
||||
pub fn query(&mut self, text: &str, modes: Option<Vec<String>>) -> io::Result<Vec<ResultItem>> {
|
||||
self.send(&Request::Query {
|
||||
text: text.to_string(),
|
||||
modes,
|
||||
})?;
|
||||
|
||||
match self.receive()? {
|
||||
Response::Results { items } => Ok(items),
|
||||
Response::Error { message } => Err(io::Error::other(message)),
|
||||
other => Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("unexpected response to Query: {other:?}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a launch event for frecency tracking.
|
||||
pub fn launch(&mut self, item_id: &str, provider: &str) -> io::Result<()> {
|
||||
self.send(&Request::Launch {
|
||||
item_id: item_id.to_string(),
|
||||
provider: provider.to_string(),
|
||||
})?;
|
||||
|
||||
match self.receive()? {
|
||||
Response::Ack => Ok(()),
|
||||
Response::Error { message } => Err(io::Error::other(message)),
|
||||
other => Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("unexpected response to Launch: {other:?}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// List all available providers from the daemon.
|
||||
pub fn providers(&mut self) -> io::Result<Vec<ProviderDesc>> {
|
||||
self.send(&Request::Providers)?;
|
||||
|
||||
match self.receive()? {
|
||||
Response::Providers { list } => Ok(list),
|
||||
Response::Error { message } => Err(io::Error::other(message)),
|
||||
other => Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("unexpected response to Providers: {other:?}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle the launcher window visibility.
|
||||
pub fn toggle(&mut self) -> io::Result<()> {
|
||||
self.send(&Request::Toggle)?;
|
||||
|
||||
match self.receive()? {
|
||||
Response::Ack => Ok(()),
|
||||
Response::Error { message } => Err(io::Error::other(message)),
|
||||
other => Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("unexpected response to Toggle: {other:?}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a plugin action command (e.g., "POMODORO:start").
|
||||
/// Returns Ok(true) if the plugin handled the action, Ok(false) if not.
|
||||
pub fn plugin_action(&mut self, command: &str) -> io::Result<bool> {
|
||||
self.send(&Request::PluginAction {
|
||||
command: command.to_string(),
|
||||
})?;
|
||||
|
||||
match self.receive()? {
|
||||
Response::Ack => Ok(true),
|
||||
Response::Error { .. } => Ok(false),
|
||||
other => Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("unexpected response to PluginAction: {other:?}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Query a plugin's submenu actions.
|
||||
pub fn submenu(&mut self, plugin_id: &str, data: &str) -> io::Result<Vec<ResultItem>> {
|
||||
self.send(&Request::Submenu {
|
||||
plugin_id: plugin_id.to_string(),
|
||||
data: data.to_string(),
|
||||
})?;
|
||||
|
||||
match self.receive()? {
|
||||
Response::SubmenuItems { items } => Ok(items),
|
||||
Response::Error { message } => Err(io::Error::other(message)),
|
||||
other => Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("unexpected response to Submenu: {other:?}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Internal helpers
|
||||
// =========================================================================
|
||||
|
||||
fn send(&mut self, request: &Request) -> io::Result<()> {
|
||||
let json = serde_json::to_string(request)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
writeln!(self.stream, "{json}")?;
|
||||
self.stream.flush()
|
||||
}
|
||||
|
||||
fn receive(&mut self) -> io::Result<Response> {
|
||||
let mut line = String::new();
|
||||
self.reader.read_line(&mut line)?;
|
||||
if line.is_empty() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::UnexpectedEof,
|
||||
"daemon closed the connection",
|
||||
));
|
||||
}
|
||||
serde_json::from_str(line.trim()).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::thread;
|
||||
|
||||
static COUNTER: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
/// Spawn a mock server that accepts one connection, reads one request,
|
||||
/// and replies with the given canned response. Each call gets a unique
|
||||
/// socket path to avoid collisions when tests run in parallel.
|
||||
fn mock_server(response: Response) -> PathBuf {
|
||||
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!("owlry-test-{}-{}", std::process::id(), n));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let sock = dir.join("test.sock");
|
||||
let _ = std::fs::remove_file(&sock);
|
||||
|
||||
let listener = UnixListener::bind(&sock).expect("bind mock socket");
|
||||
let sock_clone = sock.clone();
|
||||
|
||||
thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().expect("accept");
|
||||
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
||||
let mut writer = stream;
|
||||
|
||||
// Read one request line (we don't care about contents).
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).expect("read request");
|
||||
|
||||
// Send canned response.
|
||||
let mut json = serde_json::to_string(&response).unwrap();
|
||||
json.push('\n');
|
||||
writer.write_all(json.as_bytes()).unwrap();
|
||||
writer.flush().unwrap();
|
||||
|
||||
// Clean up socket after test.
|
||||
let _ = std::fs::remove_file(&sock_clone);
|
||||
let _ = std::fs::remove_dir(dir);
|
||||
});
|
||||
|
||||
sock
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_and_query_returns_results() {
|
||||
let canned = Response::Results {
|
||||
items: vec![ResultItem {
|
||||
id: "firefox".into(),
|
||||
title: "Firefox".into(),
|
||||
description: "Web Browser".into(),
|
||||
icon: "firefox".into(),
|
||||
provider: "app".into(),
|
||||
score: 100,
|
||||
command: Some("firefox".into()),
|
||||
terminal: false,
|
||||
tags: vec![],
|
||||
}],
|
||||
};
|
||||
|
||||
let sock = mock_server(canned);
|
||||
// Give the listener thread a moment to start.
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
|
||||
let mut client = CoreClient::connect(&sock).expect("connect");
|
||||
let results = client.query("fire", None).expect("query");
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].id, "firefox");
|
||||
assert_eq!(results[0].title, "Firefox");
|
||||
assert_eq!(results[0].score, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_returns_ack() {
|
||||
let sock = mock_server(Response::Ack);
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
|
||||
let mut client = CoreClient::connect(&sock).expect("connect");
|
||||
client.toggle().expect("toggle should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_returns_ack() {
|
||||
let sock = mock_server(Response::Ack);
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
|
||||
let mut client = CoreClient::connect(&sock).expect("connect");
|
||||
client
|
||||
.launch("firefox", "app")
|
||||
.expect("launch should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn providers_returns_list() {
|
||||
let canned = Response::Providers {
|
||||
list: vec![ProviderDesc {
|
||||
id: "app".into(),
|
||||
name: "Applications".into(),
|
||||
prefix: Some(":app".into()),
|
||||
icon: "application-x-executable".into(),
|
||||
position: "normal".into(),
|
||||
}],
|
||||
};
|
||||
|
||||
let sock = mock_server(canned);
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
|
||||
let mut client = CoreClient::connect(&sock).expect("connect");
|
||||
let providers = client.providers().expect("providers");
|
||||
|
||||
assert_eq!(providers.len(), 1);
|
||||
assert_eq!(providers[0].id, "app");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submenu_returns_items() {
|
||||
let canned = Response::SubmenuItems {
|
||||
items: vec![ResultItem {
|
||||
id: "start".into(),
|
||||
title: "Start Service".into(),
|
||||
description: String::new(),
|
||||
icon: "media-playback-start".into(),
|
||||
provider: "systemd".into(),
|
||||
score: 0,
|
||||
command: Some("systemctl --user start foo".into()),
|
||||
terminal: false,
|
||||
tags: vec![],
|
||||
}],
|
||||
};
|
||||
|
||||
let sock = mock_server(canned);
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
|
||||
let mut client = CoreClient::connect(&sock).expect("connect");
|
||||
let items = client.submenu("systemd", "foo.service").expect("submenu");
|
||||
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].id, "start");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_response_is_propagated() {
|
||||
let canned = Response::Error {
|
||||
message: "something went wrong".into(),
|
||||
};
|
||||
|
||||
let sock = mock_server(canned);
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
|
||||
let mut client = CoreClient::connect(&sock).expect("connect");
|
||||
let err = client.query("test", None).unwrap_err();
|
||||
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("something went wrong"),
|
||||
"error message should contain the server error, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn socket_path_delegates_to_core() {
|
||||
let path = CoreClient::socket_path();
|
||||
assert!(path.ends_with("owlry/owlry.sock"));
|
||||
}
|
||||
}
|
||||
@@ -1,402 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
use log::debug;
|
||||
|
||||
use crate::config::ProvidersConfig;
|
||||
use crate::providers::ProviderType;
|
||||
|
||||
/// Tracks which providers are enabled and handles prefix-based filtering
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderFilter {
|
||||
enabled: HashSet<ProviderType>,
|
||||
active_prefix: Option<ProviderType>,
|
||||
}
|
||||
|
||||
/// Result of parsing a query for prefix syntax
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParsedQuery {
|
||||
pub prefix: Option<ProviderType>,
|
||||
pub tag_filter: Option<String>,
|
||||
pub query: String,
|
||||
}
|
||||
|
||||
impl ProviderFilter {
|
||||
/// Create filter from CLI args and config
|
||||
pub fn new(
|
||||
cli_mode: Option<ProviderType>,
|
||||
cli_providers: Option<Vec<ProviderType>>,
|
||||
config_providers: &ProvidersConfig,
|
||||
) -> Self {
|
||||
let enabled = if let Some(mode) = cli_mode {
|
||||
// --mode overrides everything: single provider
|
||||
HashSet::from([mode])
|
||||
} else if let Some(providers) = cli_providers {
|
||||
// --providers overrides config
|
||||
providers.into_iter().collect()
|
||||
} else {
|
||||
// Use config file settings, default to apps only
|
||||
let mut set = HashSet::new();
|
||||
if config_providers.applications {
|
||||
set.insert(ProviderType::Application);
|
||||
}
|
||||
if config_providers.commands {
|
||||
set.insert(ProviderType::Command);
|
||||
}
|
||||
if config_providers.uuctl {
|
||||
set.insert(ProviderType::Uuctl);
|
||||
}
|
||||
if config_providers.system {
|
||||
set.insert(ProviderType::System);
|
||||
}
|
||||
if config_providers.ssh {
|
||||
set.insert(ProviderType::Ssh);
|
||||
}
|
||||
if config_providers.clipboard {
|
||||
set.insert(ProviderType::Clipboard);
|
||||
}
|
||||
if config_providers.bookmarks {
|
||||
set.insert(ProviderType::Bookmarks);
|
||||
}
|
||||
if config_providers.emoji {
|
||||
set.insert(ProviderType::Emoji);
|
||||
}
|
||||
if config_providers.scripts {
|
||||
set.insert(ProviderType::Scripts);
|
||||
}
|
||||
// Dynamic providers: add to filter set so they work in "All" mode
|
||||
// but can still be excluded when in single-provider mode
|
||||
if config_providers.files {
|
||||
set.insert(ProviderType::Files);
|
||||
}
|
||||
if config_providers.calculator {
|
||||
set.insert(ProviderType::Calculator);
|
||||
}
|
||||
if config_providers.websearch {
|
||||
set.insert(ProviderType::WebSearch);
|
||||
}
|
||||
// Default to apps if nothing enabled
|
||||
if set.is_empty() {
|
||||
set.insert(ProviderType::Application);
|
||||
}
|
||||
set
|
||||
};
|
||||
|
||||
let filter = Self {
|
||||
enabled,
|
||||
active_prefix: None,
|
||||
};
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!("[Filter] Created with enabled providers: {:?}", filter.enabled);
|
||||
|
||||
filter
|
||||
}
|
||||
|
||||
/// Default filter: apps only
|
||||
#[allow(dead_code)]
|
||||
pub fn apps_only() -> Self {
|
||||
Self {
|
||||
enabled: HashSet::from([ProviderType::Application]),
|
||||
active_prefix: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle a provider on/off
|
||||
pub fn toggle(&mut self, provider: ProviderType) {
|
||||
if self.enabled.contains(&provider) {
|
||||
self.enabled.remove(&provider);
|
||||
// Ensure at least one provider is always enabled
|
||||
if self.enabled.is_empty() {
|
||||
self.enabled.insert(ProviderType::Application);
|
||||
}
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!("[Filter] Toggled OFF {:?}, enabled: {:?}", provider, self.enabled);
|
||||
} else {
|
||||
#[cfg(feature = "dev-logging")]
|
||||
let provider_debug = format!("{:?}", provider);
|
||||
self.enabled.insert(provider);
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!("[Filter] Toggled ON {}, enabled: {:?}", provider_debug, self.enabled);
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable a specific provider
|
||||
pub fn enable(&mut self, provider: ProviderType) {
|
||||
self.enabled.insert(provider);
|
||||
}
|
||||
|
||||
/// Disable a specific provider (ensures at least one remains)
|
||||
pub fn disable(&mut self, provider: ProviderType) {
|
||||
self.enabled.remove(&provider);
|
||||
if self.enabled.is_empty() {
|
||||
self.enabled.insert(ProviderType::Application);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set to single provider mode
|
||||
pub fn set_single_mode(&mut self, provider: ProviderType) {
|
||||
self.enabled.clear();
|
||||
self.enabled.insert(provider);
|
||||
}
|
||||
|
||||
/// Set prefix mode (from :app, :cmd, etc.)
|
||||
pub fn set_prefix(&mut self, prefix: Option<ProviderType>) {
|
||||
#[cfg(feature = "dev-logging")]
|
||||
if self.active_prefix != prefix {
|
||||
debug!("[Filter] Prefix changed: {:?} -> {:?}", self.active_prefix, prefix);
|
||||
}
|
||||
self.active_prefix = prefix;
|
||||
}
|
||||
|
||||
/// Check if a provider should be searched
|
||||
pub fn is_active(&self, provider: ProviderType) -> bool {
|
||||
if let Some(ref prefix) = self.active_prefix {
|
||||
&provider == prefix
|
||||
} else {
|
||||
self.enabled.contains(&provider)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if provider is in enabled set (ignoring prefix)
|
||||
pub fn is_enabled(&self, provider: ProviderType) -> bool {
|
||||
self.enabled.contains(&provider)
|
||||
}
|
||||
|
||||
/// Get current active prefix if any
|
||||
#[allow(dead_code)]
|
||||
pub fn active_prefix(&self) -> Option<ProviderType> {
|
||||
self.active_prefix.clone()
|
||||
}
|
||||
|
||||
/// Parse query for prefix syntax
|
||||
pub fn parse_query(query: &str) -> ParsedQuery {
|
||||
let trimmed = query.trim_start();
|
||||
|
||||
// Check for tag filter pattern: ":tag:XXX query" or ":tag:XXX"
|
||||
if let Some(rest) = trimmed.strip_prefix(":tag:") {
|
||||
// Find the end of the tag (space or end of string)
|
||||
if let Some(space_idx) = rest.find(' ') {
|
||||
let tag = rest[..space_idx].to_lowercase();
|
||||
let query_part = rest[space_idx + 1..].to_string();
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!("[Filter] parse_query({:?}) -> tag={:?}, query={:?}", query, tag, query_part);
|
||||
return ParsedQuery {
|
||||
prefix: None,
|
||||
tag_filter: Some(tag),
|
||||
query: query_part,
|
||||
};
|
||||
} else {
|
||||
// Just the tag, no query yet
|
||||
let tag = rest.to_lowercase();
|
||||
return ParsedQuery {
|
||||
prefix: None,
|
||||
tag_filter: Some(tag),
|
||||
query: String::new(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check for prefix patterns (with trailing space)
|
||||
let prefixes = [
|
||||
(":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 {
|
||||
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),
|
||||
tag_filter: None,
|
||||
query: rest.to_string(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Handle prefix without trailing space (still typing)
|
||||
let partial_prefixes = [
|
||||
(":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 {
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!("[Filter] parse_query({:?}) -> partial prefix {:?}", query, provider);
|
||||
return ParsedQuery {
|
||||
prefix: Some(provider),
|
||||
tag_filter: None,
|
||||
query: String::new(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let result = ParsedQuery {
|
||||
prefix: None,
|
||||
tag_filter: None,
|
||||
query: query.to_string(),
|
||||
};
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!("[Filter] parse_query({:?}) -> prefix={:?}, tag={:?}, query={:?}", query, result.prefix, result.tag_filter, result.query);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Get enabled providers for UI display (sorted)
|
||||
pub fn enabled_providers(&self) -> Vec<ProviderType> {
|
||||
let mut providers: Vec<_> = self.enabled.iter().cloned().collect();
|
||||
providers.sort_by_key(|p| match p {
|
||||
ProviderType::Application => 0,
|
||||
ProviderType::Bookmarks => 1,
|
||||
ProviderType::Calculator => 2,
|
||||
ProviderType::Clipboard => 3,
|
||||
ProviderType::Command => 4,
|
||||
ProviderType::Dmenu => 5,
|
||||
ProviderType::Emoji => 6,
|
||||
ProviderType::Files => 7,
|
||||
ProviderType::MediaPlayer => 8,
|
||||
ProviderType::Pomodoro => 9,
|
||||
ProviderType::Scripts => 10,
|
||||
ProviderType::Ssh => 11,
|
||||
ProviderType::System => 12,
|
||||
ProviderType::Uuctl => 13,
|
||||
ProviderType::Weather => 14,
|
||||
ProviderType::WebSearch => 15,
|
||||
ProviderType::Plugin(_) => 100, // Plugin providers sort last
|
||||
});
|
||||
providers
|
||||
}
|
||||
|
||||
/// Get display name for current mode
|
||||
pub fn mode_display_name(&self) -> &'static str {
|
||||
if let Some(ref prefix) = self.active_prefix {
|
||||
return match prefix {
|
||||
ProviderType::Application => "Apps",
|
||||
ProviderType::Bookmarks => "Bookmarks",
|
||||
ProviderType::Calculator => "Calc",
|
||||
ProviderType::Clipboard => "Clipboard",
|
||||
ProviderType::Command => "Commands",
|
||||
ProviderType::Dmenu => "dmenu",
|
||||
ProviderType::Emoji => "Emoji",
|
||||
ProviderType::Files => "Files",
|
||||
ProviderType::MediaPlayer => "Media",
|
||||
ProviderType::Pomodoro => "Pomodoro",
|
||||
ProviderType::Scripts => "Scripts",
|
||||
ProviderType::Ssh => "SSH",
|
||||
ProviderType::System => "System",
|
||||
ProviderType::Uuctl => "uuctl",
|
||||
ProviderType::Weather => "Weather",
|
||||
ProviderType::WebSearch => "Web",
|
||||
ProviderType::Plugin(_) => "Plugin",
|
||||
};
|
||||
}
|
||||
|
||||
let enabled: Vec<_> = self.enabled_providers();
|
||||
if enabled.len() == 1 {
|
||||
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::MediaPlayer => "Media",
|
||||
ProviderType::Pomodoro => "Pomodoro",
|
||||
ProviderType::Scripts => "Scripts",
|
||||
ProviderType::Ssh => "SSH",
|
||||
ProviderType::System => "System",
|
||||
ProviderType::Uuctl => "uuctl",
|
||||
ProviderType::Weather => "Weather",
|
||||
ProviderType::WebSearch => "Web",
|
||||
ProviderType::Plugin(_) => "Plugin",
|
||||
}
|
||||
} else {
|
||||
"All"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_query_with_prefix() {
|
||||
let result = ProviderFilter::parse_query(":app firefox");
|
||||
assert_eq!(result.prefix, Some(ProviderType::Application));
|
||||
assert_eq!(result.query, "firefox");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_query_without_prefix() {
|
||||
let result = ProviderFilter::parse_query("firefox");
|
||||
assert_eq!(result.prefix, None);
|
||||
assert_eq!(result.query, "firefox");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_query_partial_prefix() {
|
||||
let result = ProviderFilter::parse_query(":cmd");
|
||||
assert_eq!(result.prefix, Some(ProviderType::Command));
|
||||
assert_eq!(result.query, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toggle_ensures_one_enabled() {
|
||||
let mut filter = ProviderFilter::apps_only();
|
||||
filter.toggle(ProviderType::Application);
|
||||
// Should still have apps enabled as fallback
|
||||
assert!(filter.is_enabled(ProviderType::Application));
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
mod app;
|
||||
mod backend;
|
||||
mod cli;
|
||||
mod config;
|
||||
mod data;
|
||||
mod filter;
|
||||
mod notify;
|
||||
mod paths;
|
||||
mod plugins;
|
||||
pub mod client;
|
||||
mod plugin_commands;
|
||||
mod providers;
|
||||
mod theme;
|
||||
mod ui;
|
||||
@@ -13,10 +10,43 @@ mod ui;
|
||||
use app::OwlryApp;
|
||||
use cli::{CliArgs, Command};
|
||||
use log::{info, warn};
|
||||
use std::os::unix::io::AsRawFd;
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
use log::debug;
|
||||
|
||||
/// Try to acquire an exclusive lock on the UI lock file.
|
||||
///
|
||||
/// Returns `Some(File)` if the lock was acquired (no other instance running),
|
||||
/// or `None` if another instance already holds the lock.
|
||||
/// The returned `File` must be kept alive for the duration of the process.
|
||||
fn try_acquire_lock() -> Option<std::fs::File> {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
let lock_path = owlry_core::paths::socket_path()
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("owlry-ui.lock");
|
||||
|
||||
// Ensure the parent directory exists
|
||||
if let Some(parent) = lock_path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(&lock_path)
|
||||
.ok()
|
||||
.and_then(|f| {
|
||||
let fd = f.as_raw_fd();
|
||||
let ret = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
|
||||
if ret == 0 { Some(f) } else { None }
|
||||
})
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args = CliArgs::parse_args();
|
||||
|
||||
@@ -25,7 +55,7 @@ fn main() {
|
||||
// CLI commands don't need full logging
|
||||
match command {
|
||||
Command::Plugin(plugin_cmd) => {
|
||||
if let Err(e) = plugins::commands::execute(plugin_cmd.clone()) {
|
||||
if let Err(e) = plugin_commands::execute(plugin_cmd.clone()) {
|
||||
eprintln!("Error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
@@ -35,7 +65,11 @@ fn main() {
|
||||
}
|
||||
|
||||
// No subcommand - launch the app
|
||||
let default_level = if cfg!(feature = "dev-logging") { "debug" } else { "info" };
|
||||
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()
|
||||
@@ -49,6 +83,27 @@ fn main() {
|
||||
debug!("CLI args: {:?}", args);
|
||||
}
|
||||
|
||||
// Toggle behavior: if another instance is already running, tell the daemon
|
||||
// to toggle visibility and exit immediately.
|
||||
let _lock_guard = match try_acquire_lock() {
|
||||
Some(file) => file,
|
||||
None => {
|
||||
// Another instance holds the lock — send toggle to daemon and exit
|
||||
info!("Another owlry instance detected, sending toggle");
|
||||
let socket_path = client::CoreClient::socket_path();
|
||||
if let Ok(mut client) = client::CoreClient::connect(&socket_path) {
|
||||
if let Err(e) = client.toggle() {
|
||||
eprintln!("Failed to toggle existing instance: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
} else {
|
||||
eprintln!("Another instance is running but daemon is unreachable");
|
||||
std::process::exit(1);
|
||||
}
|
||||
std::process::exit(0);
|
||||
}
|
||||
};
|
||||
|
||||
info!("Starting Owlry launcher");
|
||||
|
||||
// Diagnostic: log critical environment variables
|
||||
|
||||
@@ -7,11 +7,11 @@ use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::cli::{PluginCommand as CliPluginCommand, PluginRuntime};
|
||||
use crate::config::Config;
|
||||
use crate::paths;
|
||||
use crate::plugins::manifest::{discover_plugins, PluginManifest};
|
||||
use crate::plugins::registry::{self, RegistryClient};
|
||||
use crate::plugins::runtime_loader::{lua_runtime_available, rune_runtime_available};
|
||||
use owlry_core::config::Config;
|
||||
use owlry_core::paths;
|
||||
use owlry_core::plugins::manifest::{PluginManifest, discover_plugins};
|
||||
use owlry_core::plugins::registry::{self, RegistryClient};
|
||||
use owlry_core::plugins::runtime_loader::{lua_runtime_available, rune_runtime_available};
|
||||
|
||||
/// Result type for plugin commands
|
||||
pub type CommandResult = Result<(), String>;
|
||||
@@ -46,15 +46,30 @@ fn any_runtime_available() -> bool {
|
||||
/// Execute a plugin command
|
||||
pub fn execute(cmd: CliPluginCommand) -> CommandResult {
|
||||
match cmd {
|
||||
CliPluginCommand::List { enabled, disabled, runtime, available, refresh, json } => {
|
||||
CliPluginCommand::List {
|
||||
enabled,
|
||||
disabled,
|
||||
runtime,
|
||||
available,
|
||||
refresh,
|
||||
json,
|
||||
} => {
|
||||
if available {
|
||||
cmd_list_available(refresh, json)
|
||||
} else {
|
||||
cmd_list_installed(enabled, disabled, runtime, json)
|
||||
}
|
||||
}
|
||||
CliPluginCommand::Search { query, refresh, json } => cmd_search(&query, refresh, json),
|
||||
CliPluginCommand::Info { name, registry, json } => {
|
||||
CliPluginCommand::Search {
|
||||
query,
|
||||
refresh,
|
||||
json,
|
||||
} => cmd_search(&query, refresh, json),
|
||||
CliPluginCommand::Info {
|
||||
name,
|
||||
registry,
|
||||
json,
|
||||
} => {
|
||||
if registry {
|
||||
cmd_info_registry(&name, json)
|
||||
} else {
|
||||
@@ -74,15 +89,29 @@ pub fn execute(cmd: CliPluginCommand) -> CommandResult {
|
||||
CliPluginCommand::Update { name } => cmd_update(name.as_deref()),
|
||||
CliPluginCommand::Enable { name } => cmd_enable(&name),
|
||||
CliPluginCommand::Disable { name } => cmd_disable(&name),
|
||||
CliPluginCommand::Create { name, runtime, dir, display_name, description } => {
|
||||
CliPluginCommand::Create {
|
||||
name,
|
||||
runtime,
|
||||
dir,
|
||||
display_name,
|
||||
description,
|
||||
} => {
|
||||
check_runtime_available(runtime)?;
|
||||
cmd_create(&name, runtime, dir.as_deref(), display_name.as_deref(), description.as_deref())
|
||||
cmd_create(
|
||||
&name,
|
||||
runtime,
|
||||
dir.as_deref(),
|
||||
display_name.as_deref(),
|
||||
description.as_deref(),
|
||||
)
|
||||
}
|
||||
CliPluginCommand::Validate { path } => cmd_validate(path.as_deref()),
|
||||
CliPluginCommand::Runtimes => cmd_runtimes(),
|
||||
CliPluginCommand::Run { plugin_id, command, args } => {
|
||||
cmd_run_plugin_command(&plugin_id, &command, &args)
|
||||
}
|
||||
CliPluginCommand::Run {
|
||||
plugin_id,
|
||||
command,
|
||||
args,
|
||||
} => cmd_run_plugin_command(&plugin_id, &command, &args),
|
||||
CliPluginCommand::Commands { plugin_id } => cmd_list_commands(plugin_id.as_deref()),
|
||||
}
|
||||
}
|
||||
@@ -351,7 +380,10 @@ fn cmd_info_installed(name: &str, json_output: bool) -> CommandResult {
|
||||
});
|
||||
println!("{}", serde_json::to_string_pretty(&info).unwrap());
|
||||
} else {
|
||||
println!("Plugin: {} v{}", manifest.plugin.name, manifest.plugin.version);
|
||||
println!(
|
||||
"Plugin: {} v{}",
|
||||
manifest.plugin.name, manifest.plugin.version
|
||||
);
|
||||
println!("ID: {}", manifest.plugin.id);
|
||||
if !manifest.plugin.description.is_empty() {
|
||||
println!("Description: {}", manifest.plugin.description);
|
||||
@@ -359,11 +391,18 @@ fn cmd_info_installed(name: &str, json_output: bool) -> CommandResult {
|
||||
if !manifest.plugin.author.is_empty() {
|
||||
println!("Author: {}", manifest.plugin.author);
|
||||
}
|
||||
println!("Status: {}", if is_enabled { "enabled" } else { "disabled" });
|
||||
println!(
|
||||
"Status: {}",
|
||||
if is_enabled { "enabled" } else { "disabled" }
|
||||
);
|
||||
println!(
|
||||
"Runtime: {}{}",
|
||||
runtime,
|
||||
if runtime_available { "" } else { " (NOT INSTALLED)" }
|
||||
if runtime_available {
|
||||
""
|
||||
} else {
|
||||
" (NOT INSTALLED)"
|
||||
}
|
||||
);
|
||||
println!("Path: {}", plugin_path.display());
|
||||
println!();
|
||||
@@ -382,12 +421,25 @@ fn cmd_info_installed(name: &str, json_output: bool) -> CommandResult {
|
||||
}
|
||||
println!();
|
||||
println!("Permissions:");
|
||||
println!(" Network: {}", if manifest.permissions.network { "yes" } else { "no" });
|
||||
println!(
|
||||
" Network: {}",
|
||||
if manifest.permissions.network {
|
||||
"yes"
|
||||
} else {
|
||||
"no"
|
||||
}
|
||||
);
|
||||
if !manifest.permissions.filesystem.is_empty() {
|
||||
println!(" Filesystem: {}", manifest.permissions.filesystem.join(", "));
|
||||
println!(
|
||||
" Filesystem: {}",
|
||||
manifest.permissions.filesystem.join(", ")
|
||||
);
|
||||
}
|
||||
if !manifest.permissions.run_commands.is_empty() {
|
||||
println!(" Commands: {}", manifest.permissions.run_commands.join(", "));
|
||||
println!(
|
||||
" Commands: {}",
|
||||
manifest.permissions.run_commands.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,7 +450,8 @@ fn cmd_info_installed(name: &str, json_output: bool) -> CommandResult {
|
||||
fn cmd_info_registry(name: &str, json_output: bool) -> CommandResult {
|
||||
let client = get_registry_client();
|
||||
|
||||
let plugin = client.find(name, false)?
|
||||
let plugin = client
|
||||
.find(name, false)?
|
||||
.ok_or_else(|| format!("Plugin '{}' not found in registry", name))?;
|
||||
|
||||
if json_output {
|
||||
@@ -466,12 +519,10 @@ fn cmd_install(source: &str, force: bool) -> CommandResult {
|
||||
println!("Found: {} v{}", plugin.name, plugin.version);
|
||||
install_from_git(&plugin.repository, &plugins_dir, force)
|
||||
}
|
||||
None => {
|
||||
Err(format!(
|
||||
"Plugin '{}' not found in registry. Use a local path or git URL.",
|
||||
source
|
||||
))
|
||||
}
|
||||
None => Err(format!(
|
||||
"Plugin '{}' not found in registry. Use a local path or git URL.",
|
||||
source
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -597,8 +648,7 @@ fn cmd_remove(name: &str, yes: bool) -> CommandResult {
|
||||
}
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&plugin_path)
|
||||
.map_err(|e| format!("Failed to remove plugin: {}", e))?;
|
||||
fs::remove_dir_all(&plugin_path).map_err(|e| format!("Failed to remove plugin: {}", e))?;
|
||||
|
||||
// Also remove from disabled list if present
|
||||
if let Ok(mut config) = Config::load() {
|
||||
@@ -645,7 +695,9 @@ fn cmd_enable(name: &str) -> CommandResult {
|
||||
}
|
||||
|
||||
config.plugins.disabled_plugins.retain(|id| id != name);
|
||||
config.save().map_err(|e| format!("Failed to save config: {}", e))?;
|
||||
config
|
||||
.save()
|
||||
.map_err(|e| format!("Failed to save config: {}", e))?;
|
||||
|
||||
println!("Enabled plugin '{}'", name);
|
||||
Ok(())
|
||||
@@ -668,7 +720,9 @@ fn cmd_disable(name: &str) -> CommandResult {
|
||||
}
|
||||
|
||||
config.plugins.disabled_plugins.push(name.to_string());
|
||||
config.save().map_err(|e| format!("Failed to save config: {}", e))?;
|
||||
config
|
||||
.save()
|
||||
.map_err(|e| format!("Failed to save config: {}", e))?;
|
||||
|
||||
println!("Disabled plugin '{}'", name);
|
||||
Ok(())
|
||||
@@ -688,11 +742,13 @@ fn cmd_create(
|
||||
let plugin_dir = base_dir.join(name);
|
||||
|
||||
if plugin_dir.exists() {
|
||||
return Err(format!("Directory '{}' already exists", plugin_dir.display()));
|
||||
return Err(format!(
|
||||
"Directory '{}' already exists",
|
||||
plugin_dir.display()
|
||||
));
|
||||
}
|
||||
|
||||
fs::create_dir_all(&plugin_dir)
|
||||
.map_err(|e| format!("Failed to create directory: {}", e))?;
|
||||
fs::create_dir_all(&plugin_dir).map_err(|e| format!("Failed to create directory: {}", e))?;
|
||||
|
||||
let display = display_name.unwrap_or(name);
|
||||
let desc = description.unwrap_or("A custom owlry plugin");
|
||||
@@ -825,14 +881,28 @@ pub fn register(owlry) {{{{
|
||||
}
|
||||
}
|
||||
|
||||
println!("Created {} plugin '{}' at {}", runtime, name, plugin_dir.display());
|
||||
println!(
|
||||
"Created {} plugin '{}' at {}",
|
||||
runtime,
|
||||
name,
|
||||
plugin_dir.display()
|
||||
);
|
||||
println!();
|
||||
println!("Next steps:");
|
||||
println!(" 1. Edit {}/{} to implement your provider", name, entry_file);
|
||||
println!(" 2. Install: owlry plugin install {}", plugin_dir.display());
|
||||
println!(
|
||||
" 1. Edit {}/{} to implement your provider",
|
||||
name, entry_file
|
||||
);
|
||||
println!(
|
||||
" 2. Install: owlry plugin install {}",
|
||||
plugin_dir.display()
|
||||
);
|
||||
println!(" 3. Test: owlry (your plugin items should appear)");
|
||||
println!();
|
||||
println!("Runtime: {} (requires owlry-{} package)", runtime, entry_ext);
|
||||
println!(
|
||||
"Runtime: {} (requires owlry-{} package)",
|
||||
runtime, entry_ext
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -932,7 +1002,7 @@ fn cmd_validate(path: Option<&str>) -> CommandResult {
|
||||
|
||||
/// Show available script runtimes
|
||||
fn cmd_runtimes() -> CommandResult {
|
||||
use crate::plugins::runtime_loader::SYSTEM_RUNTIMES_DIR;
|
||||
use owlry_core::plugins::runtime_loader::SYSTEM_RUNTIMES_DIR;
|
||||
|
||||
println!("Script Runtimes:\n");
|
||||
|
||||
@@ -996,15 +1066,29 @@ fn cmd_run_plugin_command(plugin_id: &str, command: &str, args: &[String]) -> Co
|
||||
.map_err(|e| format!("Failed to parse manifest: {}", e))?;
|
||||
|
||||
// Check if plugin provides this command
|
||||
let cmd_info = manifest.provides.commands.iter().find(|c| c.name == command);
|
||||
let cmd_info = manifest
|
||||
.provides
|
||||
.commands
|
||||
.iter()
|
||||
.find(|c| c.name == command);
|
||||
if cmd_info.is_none() {
|
||||
let available: Vec<_> = manifest.provides.commands.iter().map(|c| c.name.as_str()).collect();
|
||||
let available: Vec<_> = manifest
|
||||
.provides
|
||||
.commands
|
||||
.iter()
|
||||
.map(|c| c.name.as_str())
|
||||
.collect();
|
||||
if available.is_empty() {
|
||||
return Err(format!("Plugin '{}' does not provide any CLI commands", plugin_id));
|
||||
return Err(format!(
|
||||
"Plugin '{}' does not provide any CLI commands",
|
||||
plugin_id
|
||||
));
|
||||
}
|
||||
return Err(format!(
|
||||
"Plugin '{}' does not have command '{}'. Available: {}",
|
||||
plugin_id, command, available.join(", ")
|
||||
plugin_id,
|
||||
command,
|
||||
available.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1024,16 +1108,14 @@ fn execute_plugin_command(
|
||||
command: &str,
|
||||
args: &[String],
|
||||
) -> CommandResult {
|
||||
use crate::plugins::runtime_loader::{LoadedRuntime, SYSTEM_RUNTIMES_DIR};
|
||||
use owlry_core::plugins::runtime_loader::{LoadedRuntime, SYSTEM_RUNTIMES_DIR};
|
||||
|
||||
let runtime = detect_runtime(manifest);
|
||||
|
||||
// Load the appropriate runtime
|
||||
let loaded_runtime = match runtime {
|
||||
PluginRuntime::Lua => {
|
||||
LoadedRuntime::load_lua(plugin_path.parent().unwrap_or(plugin_path))
|
||||
.map_err(|e| format!("Failed to load Lua runtime: {}", e))?
|
||||
}
|
||||
PluginRuntime::Lua => LoadedRuntime::load_lua(plugin_path.parent().unwrap_or(plugin_path))
|
||||
.map_err(|e| format!("Failed to load Lua runtime: {}", e))?,
|
||||
PluginRuntime::Rune => {
|
||||
LoadedRuntime::load_rune(plugin_path.parent().unwrap_or(plugin_path))
|
||||
.map_err(|e| format!("Failed to load Rune runtime: {}", e))?
|
||||
@@ -1047,7 +1129,10 @@ fn execute_plugin_command(
|
||||
let _query = query_parts.join(":");
|
||||
|
||||
// Find the provider from this plugin and send the command query
|
||||
let _provider_name = manifest.provides.providers.first()
|
||||
let _provider_name = manifest
|
||||
.provides
|
||||
.providers
|
||||
.first()
|
||||
.ok_or_else(|| format!("Plugin '{}' has no providers", manifest.plugin.id))?;
|
||||
|
||||
// Query the provider with the command
|
||||
@@ -1056,14 +1141,31 @@ fn execute_plugin_command(
|
||||
|
||||
// For now, we use a simpler approach: invoke the entry point with command args
|
||||
// This requires runtime support for command execution
|
||||
println!("Executing: owlry plugin run {} {} {}", manifest.plugin.id, command, args.join(" "));
|
||||
println!(
|
||||
"Executing: owlry plugin run {} {} {}",
|
||||
manifest.plugin.id,
|
||||
command,
|
||||
args.join(" ")
|
||||
);
|
||||
println!();
|
||||
println!("Note: Plugin command execution requires runtime support.");
|
||||
println!("The plugin entry point should handle CLI commands via owlry.command.register()");
|
||||
println!();
|
||||
println!("Runtime: {} ({})", runtime, if PathBuf::from(SYSTEM_RUNTIMES_DIR).join(
|
||||
match runtime { PluginRuntime::Lua => "liblua.so", PluginRuntime::Rune => "librune.so" }
|
||||
).exists() { "available" } else { "NOT INSTALLED" });
|
||||
println!(
|
||||
"Runtime: {} ({})",
|
||||
runtime,
|
||||
if PathBuf::from(SYSTEM_RUNTIMES_DIR)
|
||||
.join(match runtime {
|
||||
PluginRuntime::Lua => "liblua.so",
|
||||
PluginRuntime::Rune => "librune.so",
|
||||
})
|
||||
.exists()
|
||||
{
|
||||
"available"
|
||||
} else {
|
||||
"NOT INSTALLED"
|
||||
}
|
||||
);
|
||||
|
||||
// TODO: Implement actual command execution through runtime
|
||||
// This would involve:
|
||||
@@ -1087,7 +1189,8 @@ fn cmd_list_commands(plugin_id: Option<&str>) -> CommandResult {
|
||||
|
||||
if let Some(id) = plugin_id {
|
||||
// Show commands for a specific plugin
|
||||
let (manifest, _path) = discovered.get(id)
|
||||
let (manifest, _path) = discovered
|
||||
.get(id)
|
||||
.ok_or_else(|| format!("Plugin '{}' not found", id))?;
|
||||
|
||||
if manifest.provides.commands.is_empty() {
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::{LaunchItem, Provider, ProviderType};
|
||||
use log::debug;
|
||||
use owlry_core::providers::{LaunchItem, Provider, ProviderType};
|
||||
use std::io::{self, BufRead};
|
||||
|
||||
/// Provider for dmenu-style input from stdin
|
||||
|
||||
@@ -1,617 +1,2 @@
|
||||
// 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 mod dmenu;
|
||||
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
|
||||
///
|
||||
/// Note: Plugin is a special case that stores a type_id string
|
||||
/// for custom plugin-defined provider types.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum ProviderType {
|
||||
Application,
|
||||
Bookmarks,
|
||||
Calculator,
|
||||
Clipboard,
|
||||
Command,
|
||||
Dmenu,
|
||||
Emoji,
|
||||
Files,
|
||||
MediaPlayer,
|
||||
Pomodoro,
|
||||
Scripts,
|
||||
Ssh,
|
||||
System,
|
||||
Uuctl,
|
||||
Weather,
|
||||
WebSearch,
|
||||
/// Plugin-defined provider type with custom type_id
|
||||
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() {
|
||||
"app" | "apps" | "application" | "applications" => Ok(ProviderType::Application),
|
||||
"bookmark" | "bookmarks" | "bm" => Ok(ProviderType::Bookmarks),
|
||||
"calc" | "calculator" => Ok(ProviderType::Calculator),
|
||||
"clip" | "clipboard" => Ok(ProviderType::Clipboard),
|
||||
"cmd" | "command" | "commands" => Ok(ProviderType::Command),
|
||||
"dmenu" => Ok(ProviderType::Dmenu),
|
||||
"emoji" | "emojis" => Ok(ProviderType::Emoji),
|
||||
"file" | "files" | "find" | "filesearch" => Ok(ProviderType::Files),
|
||||
"media" | "mpris" | "player" => Ok(ProviderType::MediaPlayer),
|
||||
"pomo" | "pomodoro" | "timer" => Ok(ProviderType::Pomodoro),
|
||||
"script" | "scripts" => Ok(ProviderType::Scripts),
|
||||
"ssh" => Ok(ProviderType::Ssh),
|
||||
"sys" | "system" | "power" => Ok(ProviderType::System),
|
||||
"uuctl" | "systemd" => Ok(ProviderType::Uuctl),
|
||||
"weather" => Ok(ProviderType::Weather),
|
||||
"web" | "websearch" | "search" => Ok(ProviderType::WebSearch),
|
||||
// Plugin types are prefixed with "plugin:" (e.g., "plugin:github-repos")
|
||||
other if other.starts_with("plugin:") => {
|
||||
Ok(ProviderType::Plugin(other[7..].to_string()))
|
||||
}
|
||||
// Unknown types become plugin types
|
||||
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::Bookmarks => write!(f, "bookmark"),
|
||||
ProviderType::Calculator => write!(f, "calc"),
|
||||
ProviderType::Clipboard => write!(f, "clip"),
|
||||
ProviderType::Command => write!(f, "cmd"),
|
||||
ProviderType::Dmenu => write!(f, "dmenu"),
|
||||
ProviderType::Emoji => write!(f, "emoji"),
|
||||
ProviderType::Files => write!(f, "file"),
|
||||
ProviderType::MediaPlayer => write!(f, "media"),
|
||||
ProviderType::Pomodoro => write!(f, "pomo"),
|
||||
ProviderType::Scripts => write!(f, "script"),
|
||||
ProviderType::Ssh => write!(f, "ssh"),
|
||||
ProviderType::System => write!(f, "sys"),
|
||||
ProviderType::Uuctl => write!(f, "uuctl"),
|
||||
ProviderType::Weather => write!(f, "weather"),
|
||||
ProviderType::WebSearch => write!(f, "web"),
|
||||
ProviderType::Plugin(type_id) => write!(f, "{}", type_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait for all search providers
|
||||
pub trait Provider: Send {
|
||||
#[allow(dead_code)]
|
||||
fn name(&self) -> &str;
|
||||
fn provider_type(&self) -> ProviderType;
|
||||
fn refresh(&mut self);
|
||||
fn items(&self) -> &[LaunchItem];
|
||||
}
|
||||
|
||||
/// Manages all providers and handles searching
|
||||
pub struct ProviderManager {
|
||||
/// Static providers (apps, commands, and native static plugins)
|
||||
providers: Vec<Box<dyn Provider>>,
|
||||
/// Dynamic providers from native plugins (calculator, websearch, filesearch)
|
||||
/// These are queried per-keystroke, not cached
|
||||
dynamic_providers: Vec<NativeProvider>,
|
||||
/// Widget providers from native plugins (weather, media, pomodoro)
|
||||
/// These appear at the top of results
|
||||
widget_providers: Vec<NativeProvider>,
|
||||
/// Fuzzy matcher for search
|
||||
matcher: SkimMatcherV2,
|
||||
}
|
||||
|
||||
/// Known dynamic provider type IDs (need per-query evaluation)
|
||||
const DYNAMIC_TYPE_IDS: &[&str] = &["calc", "websearch", "filesearch"];
|
||||
|
||||
/// Known widget provider type IDs (appear at top of results)
|
||||
const WIDGET_TYPE_IDS: &[&str] = &["weather", "media", "pomodoro"];
|
||||
|
||||
impl ProviderManager {
|
||||
/// Create a new ProviderManager with native plugins
|
||||
///
|
||||
/// Native plugins are loaded from /usr/lib/owlry/plugins/ and categorized into:
|
||||
/// - Static providers (added to providers vec)
|
||||
/// - Dynamic providers (queried per-keystroke: calculator, websearch, filesearch)
|
||||
/// - Widget providers (shown at top: weather, media, pomodoro)
|
||||
pub fn with_native_plugins(native_providers: Vec<NativeProvider>) -> Self {
|
||||
let mut manager = Self {
|
||||
providers: Vec::new(),
|
||||
dynamic_providers: Vec::new(),
|
||||
widget_providers: Vec::new(),
|
||||
matcher: SkimMatcherV2::default(),
|
||||
};
|
||||
|
||||
// Check if running in dmenu mode (stdin has data)
|
||||
let dmenu_mode = DmenuProvider::has_stdin_data();
|
||||
|
||||
if dmenu_mode {
|
||||
// In dmenu mode, only use dmenu provider
|
||||
let mut dmenu = DmenuProvider::new();
|
||||
dmenu.enable();
|
||||
manager.providers.push(Box::new(dmenu));
|
||||
} else {
|
||||
// Core providers (no plugin equivalents)
|
||||
manager.providers.push(Box::new(ApplicationProvider::new()));
|
||||
manager.providers.push(Box::new(CommandProvider::new()));
|
||||
|
||||
// Categorize native plugins
|
||||
for provider in native_providers {
|
||||
let type_id = provider.type_id();
|
||||
|
||||
if DYNAMIC_TYPE_IDS.contains(&type_id) {
|
||||
info!("Registered dynamic provider: {} ({})", provider.name(), type_id);
|
||||
manager.dynamic_providers.push(provider);
|
||||
} else if WIDGET_TYPE_IDS.contains(&type_id) {
|
||||
info!("Registered widget provider: {} ({})", provider.name(), type_id);
|
||||
manager.widget_providers.push(provider);
|
||||
} else {
|
||||
info!("Registered static provider: {} ({})", provider.name(), type_id);
|
||||
manager.providers.push(Box::new(provider));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initial refresh
|
||||
manager.refresh_all();
|
||||
|
||||
manager
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn is_dmenu_mode(&self) -> bool {
|
||||
self.providers
|
||||
.iter()
|
||||
.any(|p| p.provider_type() == ProviderType::Dmenu)
|
||||
}
|
||||
|
||||
pub fn refresh_all(&mut self) {
|
||||
// Refresh static providers (fast, local operations)
|
||||
for provider in &mut self.providers {
|
||||
provider.refresh();
|
||||
info!(
|
||||
"Provider '{}' loaded {} items",
|
||||
provider.name(),
|
||||
provider.items().len()
|
||||
);
|
||||
}
|
||||
|
||||
// Widget providers are refreshed separately to avoid blocking startup
|
||||
// Call refresh_widgets() after window is shown
|
||||
|
||||
// Dynamic providers don't need refresh (they query on demand)
|
||||
}
|
||||
|
||||
/// Refresh widget providers (weather, media, pomodoro)
|
||||
/// Call this separately from refresh_all() to avoid blocking startup
|
||||
/// since widgets may make network requests or spawn processes
|
||||
pub fn refresh_widgets(&mut self) {
|
||||
for provider in &mut self.widget_providers {
|
||||
provider.refresh();
|
||||
info!(
|
||||
"Widget '{}' loaded {} items",
|
||||
provider.name(),
|
||||
provider.items().len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a native provider by type ID
|
||||
/// Searches in widget providers and dynamic providers
|
||||
pub fn find_native_provider(&self, type_id: &str) -> Option<&NativeProvider> {
|
||||
// Check widget providers first (pomodoro, weather, media)
|
||||
if let Some(p) = self.widget_providers.iter().find(|p| p.type_id() == type_id) {
|
||||
return Some(p);
|
||||
}
|
||||
// Then dynamic providers (calc, websearch, filesearch)
|
||||
self.dynamic_providers.iter().find(|p| p.type_id() == type_id)
|
||||
}
|
||||
|
||||
/// Execute a plugin action command
|
||||
/// Command format: PLUGIN_ID:action_data (e.g., "POMODORO:start", "SYSTEMD:unit:restart")
|
||||
/// Returns true if the command was handled by a plugin
|
||||
pub fn execute_plugin_action(&self, command: &str) -> bool {
|
||||
// Parse command format: PLUGIN_ID:action_data
|
||||
if let Some(colon_pos) = command.find(':') {
|
||||
let plugin_id = &command[..colon_pos];
|
||||
let action = command; // Pass full command to plugin
|
||||
|
||||
// Find provider by type ID (case-insensitive for convenience)
|
||||
let type_id = plugin_id.to_lowercase();
|
||||
|
||||
if let Some(provider) = self.find_native_provider(&type_id) {
|
||||
provider.execute_action(action);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Add a dynamic provider (e.g., from a Lua plugin)
|
||||
#[allow(dead_code)]
|
||||
pub fn add_provider(&mut self, provider: Box<dyn Provider>) {
|
||||
info!("Added plugin provider: {}", provider.name());
|
||||
self.providers.push(provider);
|
||||
}
|
||||
|
||||
/// Add multiple providers at once (for batch plugin loading)
|
||||
#[allow(dead_code)]
|
||||
pub fn add_providers(&mut self, providers: Vec<Box<dyn Provider>>) {
|
||||
for provider in providers {
|
||||
self.add_provider(provider);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn search(&self, query: &str, max_results: usize) -> Vec<(LaunchItem, i64)> {
|
||||
if query.is_empty() {
|
||||
// Return recent/popular items when query is empty
|
||||
return self.providers
|
||||
.iter()
|
||||
.flat_map(|p| p.items().iter().cloned())
|
||||
.take(max_results)
|
||||
.map(|item| (item, 0))
|
||||
.collect();
|
||||
}
|
||||
|
||||
let mut results: Vec<(LaunchItem, i64)> = self.providers
|
||||
.iter()
|
||||
.flat_map(|provider| {
|
||||
provider.items().iter().filter_map(|item| {
|
||||
// Match against name and description
|
||||
let name_score = self.matcher.fuzzy_match(&item.name, query);
|
||||
let desc_score = item.description
|
||||
.as_ref()
|
||||
.and_then(|d| self.matcher.fuzzy_match(d, query));
|
||||
|
||||
let score = match (name_score, desc_score) {
|
||||
(Some(n), Some(d)) => Some(n.max(d)),
|
||||
(Some(n), None) => Some(n),
|
||||
(None, Some(d)) => Some(d / 2), // Lower weight for description matches
|
||||
(None, None) => None,
|
||||
};
|
||||
|
||||
score.map(|s| (item.clone(), s))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort by score (descending)
|
||||
results.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
results.truncate(max_results);
|
||||
results
|
||||
}
|
||||
|
||||
/// Search with provider filtering
|
||||
pub fn search_filtered(
|
||||
&self,
|
||||
query: &str,
|
||||
max_results: usize,
|
||||
filter: &crate::filter::ProviderFilter,
|
||||
) -> Vec<(LaunchItem, i64)> {
|
||||
if query.is_empty() {
|
||||
return self
|
||||
.providers
|
||||
.iter()
|
||||
.filter(|p| filter.is_active(p.provider_type()))
|
||||
.flat_map(|p| p.items().iter().cloned())
|
||||
.take(max_results)
|
||||
.map(|item| (item, 0))
|
||||
.collect();
|
||||
}
|
||||
|
||||
let mut results: Vec<(LaunchItem, i64)> = self
|
||||
.providers
|
||||
.iter()
|
||||
.filter(|provider| filter.is_active(provider.provider_type()))
|
||||
.flat_map(|provider| {
|
||||
provider.items().iter().filter_map(|item| {
|
||||
let name_score = self.matcher.fuzzy_match(&item.name, query);
|
||||
let desc_score = item
|
||||
.description
|
||||
.as_ref()
|
||||
.and_then(|d| self.matcher.fuzzy_match(d, query));
|
||||
|
||||
let score = match (name_score, desc_score) {
|
||||
(Some(n), Some(d)) => Some(n.max(d)),
|
||||
(Some(n), None) => Some(n),
|
||||
(None, Some(d)) => Some(d / 2),
|
||||
(None, None) => None,
|
||||
};
|
||||
|
||||
score.map(|s| (item.clone(), s))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
results.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
results.truncate(max_results);
|
||||
results
|
||||
}
|
||||
|
||||
/// Search with frecency boosting, dynamic providers, and tag filtering
|
||||
pub fn search_with_frecency(
|
||||
&self,
|
||||
query: &str,
|
||||
max_results: usize,
|
||||
filter: &crate::filter::ProviderFilter,
|
||||
frecency: &FrecencyStore,
|
||||
frecency_weight: f64,
|
||||
tag_filter: Option<&str>,
|
||||
) -> Vec<(LaunchItem, i64)> {
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!("[Search] query={:?}, max={}, frecency_weight={}", query, max_results, frecency_weight);
|
||||
|
||||
let mut results: Vec<(LaunchItem, i64)> = Vec::new();
|
||||
|
||||
// Add widget items first (highest priority) - only when:
|
||||
// 1. No specific filter prefix is active
|
||||
// 2. Query is empty (user hasn't started searching)
|
||||
// This keeps widgets visible on launch but hides them during active search
|
||||
if filter.active_prefix().is_none() && query.is_empty() {
|
||||
// Widget priority scores based on type
|
||||
for provider in &self.widget_providers {
|
||||
// Skip if this provider type is filtered out
|
||||
if !filter.is_active(provider.provider_type()) {
|
||||
continue;
|
||||
}
|
||||
let base_score = match provider.type_id() {
|
||||
"weather" => 12000,
|
||||
"pomodoro" => 11500,
|
||||
"media" => 11000,
|
||||
_ => 10500,
|
||||
};
|
||||
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_idx, provider) in self.dynamic_providers.iter().enumerate() {
|
||||
// Skip if this provider type is explicitly filtered out
|
||||
if !filter.is_active(provider.provider_type()) {
|
||||
continue;
|
||||
}
|
||||
let dynamic_results = provider.query(query);
|
||||
let base_score = match provider.type_id() {
|
||||
"calc" => 10000,
|
||||
"websearch" => 9000,
|
||||
"filesearch" => 8000,
|
||||
_ => 7000 - (provider_idx as i64 * 1000),
|
||||
};
|
||||
for (idx, item) in dynamic_results.into_iter().enumerate() {
|
||||
results.push((item, base_score - idx as i64));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty query (after checking special providers) - return frecency-sorted items
|
||||
if query.is_empty() {
|
||||
let items: Vec<(LaunchItem, i64)> = self
|
||||
.providers
|
||||
.iter()
|
||||
.filter(|p| filter.is_active(p.provider_type()))
|
||||
.flat_map(|p| p.items().iter().cloned())
|
||||
.filter(|item| {
|
||||
// Apply tag filter if present
|
||||
if let Some(tag) = tag_filter {
|
||||
item.tags.iter().any(|t| t.to_lowercase().contains(tag))
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.map(|item| {
|
||||
let frecency_score = frecency.get_score(&item.id);
|
||||
let boosted = (frecency_score * frecency_weight * 100.0) as i64;
|
||||
(item, boosted)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Combine widgets (already in results) with frecency items
|
||||
results.extend(items);
|
||||
results.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
results.truncate(max_results);
|
||||
return results;
|
||||
}
|
||||
|
||||
// Regular search with frecency boost and tag matching
|
||||
let search_results: Vec<(LaunchItem, i64)> = self
|
||||
.providers
|
||||
.iter()
|
||||
.filter(|provider| filter.is_active(provider.provider_type()))
|
||||
.flat_map(|provider| {
|
||||
provider.items().iter().filter_map(|item| {
|
||||
// Apply tag filter if present
|
||||
if let Some(tag) = tag_filter
|
||||
&& !item.tags.iter().any(|t| t.to_lowercase().contains(tag)) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let name_score = self.matcher.fuzzy_match(&item.name, query);
|
||||
let desc_score = item
|
||||
.description
|
||||
.as_ref()
|
||||
.and_then(|d| self.matcher.fuzzy_match(d, query));
|
||||
|
||||
// Also match against tags (lower weight)
|
||||
let tag_score = item
|
||||
.tags
|
||||
.iter()
|
||||
.filter_map(|t| self.matcher.fuzzy_match(t, query))
|
||||
.max()
|
||||
.map(|s| s / 3); // Lower weight for tag matches
|
||||
|
||||
let base_score = match (name_score, desc_score, tag_score) {
|
||||
(Some(n), Some(d), Some(t)) => Some(n.max(d).max(t)),
|
||||
(Some(n), Some(d), None) => Some(n.max(d)),
|
||||
(Some(n), None, Some(t)) => Some(n.max(t)),
|
||||
(Some(n), None, None) => Some(n),
|
||||
(None, Some(d), Some(t)) => Some((d / 2).max(t)),
|
||||
(None, Some(d), None) => Some(d / 2),
|
||||
(None, None, Some(t)) => Some(t),
|
||||
(None, None, None) => None,
|
||||
};
|
||||
|
||||
base_score.map(|s| {
|
||||
let frecency_score = frecency.get_score(&item.id);
|
||||
let frecency_boost = (frecency_score * frecency_weight * 10.0) as i64;
|
||||
(item.clone(), s + frecency_boost)
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
results.extend(search_results);
|
||||
results.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
results.truncate(max_results);
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
{
|
||||
debug!("[Search] Returning {} results", results.len());
|
||||
for (i, (item, score)) in results.iter().take(5).enumerate() {
|
||||
debug!("[Search] #{}: {} (score={}, provider={:?})", i + 1, item.name, score, item.provider);
|
||||
}
|
||||
if results.len() > 5 {
|
||||
debug!("[Search] ... and {} more", results.len() - 5);
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
/// Get all available provider types (for UI tabs)
|
||||
#[allow(dead_code)]
|
||||
pub fn available_providers(&self) -> Vec<ProviderType> {
|
||||
self.providers.iter().map(|p| p.provider_type()).collect()
|
||||
}
|
||||
|
||||
/// Get a widget item by type_id (e.g., "pomodoro", "weather", "media")
|
||||
/// Returns the first item from the widget provider, if any
|
||||
pub fn get_widget_item(&self, type_id: &str) -> Option<LaunchItem> {
|
||||
self.widget_providers
|
||||
.iter()
|
||||
.find(|p| p.type_id() == type_id)
|
||||
.and_then(|p| p.items().first().cloned())
|
||||
}
|
||||
|
||||
/// Get all loaded widget provider type_ids
|
||||
/// Returns an iterator over the type_ids of currently loaded widget providers
|
||||
pub fn widget_type_ids(&self) -> impl Iterator<Item = &str> {
|
||||
self.widget_providers.iter().map(|p| p.type_id())
|
||||
}
|
||||
|
||||
/// Query a plugin for submenu actions
|
||||
///
|
||||
/// This is used when a user selects a SUBMENU:plugin_id:data item.
|
||||
/// The plugin is queried with "?SUBMENU:data" and returns action items.
|
||||
///
|
||||
/// Returns (display_name, actions) where display_name is the item name
|
||||
/// and actions are the submenu items returned by the plugin.
|
||||
pub fn query_submenu_actions(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
data: &str,
|
||||
display_name: &str,
|
||||
) -> Option<(String, Vec<LaunchItem>)> {
|
||||
// Build the submenu query
|
||||
let submenu_query = format!("?SUBMENU:{}", data);
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"[Submenu] Querying plugin '{}' with: {}",
|
||||
plugin_id, submenu_query
|
||||
);
|
||||
|
||||
// Search in dynamic providers
|
||||
for provider in &self.dynamic_providers {
|
||||
if provider.type_id() == plugin_id {
|
||||
let actions = provider.query(&submenu_query);
|
||||
if !actions.is_empty() {
|
||||
return Some((display_name.to_string(), actions));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search in widget providers
|
||||
for provider in &self.widget_providers {
|
||||
if provider.type_id() == plugin_id {
|
||||
let actions = provider.query(&submenu_query);
|
||||
if !actions.is_empty() {
|
||||
return Some((display_name.to_string(), actions));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search in static providers (boxed)
|
||||
// Note: Static providers don't typically have submenu support,
|
||||
// but we check for completeness
|
||||
for provider in &self.providers {
|
||||
if let ProviderType::Plugin(type_id) = provider.provider_type()
|
||||
&& type_id == plugin_id
|
||||
{
|
||||
// Static providers use the items() method, not query
|
||||
// Submenu support requires dynamic query capability
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"[Submenu] Plugin '{}' is static, cannot query for submenu",
|
||||
plugin_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!("[Submenu] No submenu actions found for plugin '{}'", plugin_id);
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<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>
|
||||
|
||||
|
Before Width: | Height: | Size: 188 B After Width: | Height: | Size: 183 B |
@@ -1,7 +1,7 @@
|
||||
<?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" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
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
|
||||
|
||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -1,7 +1,7 @@
|
||||
<?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" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
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
|
||||
|
||||
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.8 KiB |
@@ -1,7 +1,7 @@
|
||||
<?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" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
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
|
||||
|
||||
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
@@ -1,7 +1,7 @@
|
||||
<?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" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
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
|
||||
|
||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
@@ -1,7 +1,7 @@
|
||||
<?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" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
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
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
@@ -1,7 +1,7 @@
|
||||
<?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" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
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
|
||||
|
||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
@@ -1,7 +1,7 @@
|
||||
<?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" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
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
|
||||
|
||||
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
@@ -1,7 +1,7 @@
|
||||
<?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" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
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
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |