Compare commits
92 Commits
plugin-api
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7275fcab35 | |||
| 4d7e913657 | |||
| f8d011447e | |||
| 9163b1ea6c | |||
| 6586f5d6c2 | |||
| a6e94deb3c | |||
| de74cac67d | |||
| 2f396306fd | |||
| 133d5264ea | |||
| a16c3a0523 | |||
| 33b4f410e5 | |||
| a7683f16bf | |||
| 178f81082a | |||
| 7863de9971 | |||
| dacc194d02 | |||
| 5871609c73 | |||
| e3c4988e01 | |||
| 46b5d8518f | |||
| 95a698225c | |||
| 709e1b04cb | |||
| 827bf383ea | |||
| b706347ec9 | |||
| 32b4b144f4 | |||
| 5615002062 | |||
| 0a3af9fa56 | |||
| c93b11e899 | |||
| bd69f8eafe | |||
| edfb079bb1 | |||
| 3de382cd73 | |||
| 82f35e5a54 | |||
| a920588df9 | |||
| c32b6c5456 | |||
| 2a5f184230 | |||
| b2f068269a | |||
| e210a604f7 | |||
| 1adec7bf47 | |||
| 7f07a93dec | |||
| 7351ba868e | |||
| 44e1430ea5 | |||
| 80312a28f7 | |||
| 37abe98c9b | |||
| d95b81bbcb | |||
| 562b38deba | |||
| 2888677e38 | |||
| 940ad58ee2 | |||
| 18775d71fc | |||
| f189f4b1ce | |||
| 422ea6d816 | |||
| 8b444eec3b | |||
| 6d0bf1c401 | |||
| c8d8298274 | |||
| 62f6e1d4b0 | |||
| bf1d759cb2 | |||
| 3f9f4bb112 | |||
| c5f1f35167 | |||
| 81626c33dd | |||
| 99d38a66b8 | |||
| 8b4c704501 | |||
| 27e296e333 | |||
| 173d72ad43 | |||
| 3eea902c7f | |||
| a12e850c94 | |||
| eccfb217d4 | |||
| c3c35611fd | |||
| 5ecd0a6412 | |||
| 6fe7213b6f | |||
| b768bfd181 | |||
| c9a1ff28f4 | |||
| 623572ec14 | |||
| 5196255594 | |||
| b87447156e | |||
| 12d554959a | |||
| 83fa22d84c | |||
| ade5d3aeef | |||
| 617c943147 | |||
| 1b1e12124b | |||
| 94556f1fe0 | |||
| 2b98f0651c | |||
| 75fa770c94 | |||
| c6ba91f06d | |||
| 235103e854 | |||
| 8ccaaf28c8 | |||
| cfd143fe4a | |||
| 10a685c62f | |||
| 34db33c75f | |||
| 4bff83b5e6 | |||
| 8f7501038d | |||
| 4032205800 | |||
| 99985c7f3b | |||
| 6113217f7b | |||
| 558d415e12 | |||
| 6bde1504b1 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,8 +1,10 @@
|
||||
/target
|
||||
CLAUDE.md
|
||||
.worktrees/
|
||||
media.md
|
||||
|
||||
# AUR packages (each is its own git repo for aur.archlinux.org)
|
||||
# Track PKGBUILD and .SRCINFO, ignore build artifacts and sub-repo .git
|
||||
aur/*/.git/
|
||||
aur/*/pkg/
|
||||
aur/*/src/
|
||||
@@ -10,6 +12,3 @@ aur/*/*.tar.zst
|
||||
aur/*/*.tar.gz
|
||||
aur/*/*.tar.xz
|
||||
aur/*/*.pkg.tar.*
|
||||
# Keep PKGBUILD and .SRCINFO tracked
|
||||
.SRCINFO
|
||||
aur/
|
||||
|
||||
924
Cargo.lock
generated
924
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
197
README.md
197
README.md
@@ -11,16 +11,18 @@ 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
|
||||
- **Built-in providers** — Calculator, unit/currency converter, and system actions out of the box
|
||||
- **Built-in settings editor** — Configure everything from within the launcher (`:config`)
|
||||
- **11 optional plugins** — Clipboard, emoji, weather, media, bookmarks, and more
|
||||
- **Widget providers** — Weather, media controls, and pomodoro timer at the top of results
|
||||
- **Fuzzy search with tags** — Fast matching across names, descriptions, and category tags
|
||||
- **Config profiles** — Named mode presets for different workflows
|
||||
- **Filter prefixes** — Scope searches with `:app`, `:cmd`, `:tag:development`, etc.
|
||||
- **Filter prefixes** — Scope searches with `:app`, `:cmd`, `:config`, `:tag:X`, 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
|
||||
- **GTK4 theming** — System theme by default, with 10 built-in themes
|
||||
- **Wayland native** — Uses Layer Shell for proper overlay behavior
|
||||
- **dmenu compatible** — Pipe-based selection mode, no daemon required
|
||||
- **Extensible** — Create custom plugins in Lua or Rune
|
||||
|
||||
## Installation
|
||||
@@ -28,41 +30,45 @@ A lightweight, owl-themed application launcher for Wayland, built with GTK4 and
|
||||
### Arch Linux (AUR)
|
||||
|
||||
```bash
|
||||
# Minimal core (applications + commands only)
|
||||
# Core (includes calculator, converter, system actions, settings editor)
|
||||
yay -S owlry
|
||||
|
||||
# Add individual plugins
|
||||
yay -S owlry-plugin-calculator owlry-plugin-weather
|
||||
# Add individual plugins as needed
|
||||
yay -S owlry-plugin-bookmarks owlry-plugin-weather owlry-plugin-clipboard
|
||||
|
||||
# Or install bundles:
|
||||
yay -S owlry-meta-essentials # calculator, system, ssh, scripts, bookmarks
|
||||
yay -S owlry-meta-widgets # weather, media, pomodoro
|
||||
yay -S owlry-meta-tools # clipboard, emoji, websearch, filesearch, systemd
|
||||
yay -S owlry-meta-full # everything
|
||||
|
||||
# For custom Lua/Rune plugins
|
||||
# For custom Lua/Rune user plugins
|
||||
yay -S owlry-lua # Lua 5.4 runtime
|
||||
yay -S owlry-rune # Rune runtime
|
||||
```
|
||||
|
||||
### Available Packages
|
||||
|
||||
**Core packages** (this repo):
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `owlry` | Core: UI client (`owlry`) and daemon (`owlry-core`) |
|
||||
| `owlry-plugin-calculator` | Math expressions (`= 5+3`) |
|
||||
| `owlry-plugin-system` | Shutdown, reboot, suspend, lock |
|
||||
| `owlry-plugin-ssh` | SSH hosts from `~/.ssh/config` |
|
||||
| `owlry` | GTK4 UI client |
|
||||
| `owlry-core` | Daemon (`owlryd`) with built-in calculator, converter, system, and settings providers |
|
||||
| `owlry-lua` | Lua 5.4 script runtime for user plugins |
|
||||
| `owlry-rune` | Rune script runtime for user plugins |
|
||||
|
||||
**Plugin packages** ([owlry-plugins](https://somegit.dev/Owlibou/owlry-plugins) repo):
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `owlry-plugin-bookmarks` | Firefox, Chrome, Brave, Edge bookmarks |
|
||||
| `owlry-plugin-clipboard` | History via cliphist |
|
||||
| `owlry-plugin-emoji` | 400+ searchable emoji |
|
||||
| `owlry-plugin-scripts` | User scripts |
|
||||
| `owlry-plugin-bookmarks` | Firefox, Chrome, Brave, Edge bookmarks |
|
||||
| `owlry-plugin-websearch` | Web search (`? query`) |
|
||||
| `owlry-plugin-filesearch` | File search (`/ filename`) |
|
||||
| `owlry-plugin-systemd` | User services with actions |
|
||||
| `owlry-plugin-weather` | Weather widget |
|
||||
| `owlry-plugin-media` | MPRIS media controls |
|
||||
| `owlry-plugin-pomodoro` | Pomodoro timer widget |
|
||||
| `owlry-plugin-scripts` | User scripts |
|
||||
| `owlry-plugin-ssh` | SSH hosts from `~/.ssh/config` |
|
||||
| `owlry-plugin-systemd` | User services with actions |
|
||||
| `owlry-plugin-weather` | Weather widget |
|
||||
| `owlry-plugin-websearch` | Web search (`? query`) |
|
||||
|
||||
> **Note:** Calculator, converter, and system actions are built into `owlry-core` and do not require separate packages.
|
||||
|
||||
### Build from Source
|
||||
|
||||
@@ -83,26 +89,33 @@ sudo dnf install gtk4-devel gtk4-layer-shell-devel
|
||||
git clone https://somegit.dev/Owlibou/owlry.git
|
||||
cd owlry
|
||||
|
||||
# Build core only (daemon + UI)
|
||||
# Build daemon + UI
|
||||
cargo build --release -p owlry -p owlry-core
|
||||
|
||||
# Build specific plugin
|
||||
cargo build --release -p owlry-plugin-calculator
|
||||
# Build runtimes (for user plugins)
|
||||
cargo build --release -p owlry-lua -p owlry-rune
|
||||
|
||||
# Build everything
|
||||
# Build everything in this workspace
|
||||
cargo build --release --workspace
|
||||
```
|
||||
|
||||
**Plugins** are in a [separate repo](https://somegit.dev/Owlibou/owlry-plugins):
|
||||
```bash
|
||||
git clone https://somegit.dev/Owlibou/owlry-plugins.git
|
||||
cd owlry-plugins
|
||||
cargo build --release -p owlry-plugin-bookmarks # or any plugin
|
||||
```
|
||||
|
||||
**Install locally:**
|
||||
```bash
|
||||
just install-local
|
||||
```
|
||||
|
||||
This installs both binaries, all plugins, runtimes, and the systemd service files.
|
||||
This installs the UI (`owlry`), daemon (`owlryd`), runtimes, and 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.
|
||||
Owlry uses a client/daemon architecture. The daemon (`owlryd`) loads providers and plugins into memory. The UI client (`owlry`) connects to the daemon over a Unix socket for instant results.
|
||||
|
||||
### Starting the Daemon
|
||||
|
||||
@@ -114,25 +127,25 @@ Add to your compositor config:
|
||||
|
||||
```bash
|
||||
# Hyprland (~/.config/hypr/hyprland.conf)
|
||||
exec-once = owlry-core
|
||||
exec-once = owlryd
|
||||
|
||||
# Sway (~/.config/sway/config)
|
||||
exec owlry-core
|
||||
exec owlryd
|
||||
```
|
||||
|
||||
**2. Systemd user service**
|
||||
|
||||
```bash
|
||||
systemctl --user enable --now owlry-core.service
|
||||
systemctl --user enable --now owlryd.service
|
||||
```
|
||||
|
||||
**3. Socket activation (auto-start on first use)**
|
||||
|
||||
```bash
|
||||
systemctl --user enable owlry-core.socket
|
||||
systemctl --user enable owlryd.socket
|
||||
```
|
||||
|
||||
The daemon starts automatically when the UI client first connects. No manual startup needed.
|
||||
The daemon starts automatically when the UI client first connects.
|
||||
|
||||
### Launching the UI
|
||||
|
||||
@@ -146,7 +159,7 @@ bind = SUPER, Space, exec, owlry
|
||||
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.
|
||||
Running `owlry` a second time while it is already open sends a toggle command — the window closes. 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.
|
||||
|
||||
@@ -156,7 +169,7 @@ If the daemon is not running when the UI launches, it will attempt to start it v
|
||||
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 -m calc # Calculator only
|
||||
owlry --profile dev # Use a named profile from config
|
||||
owlry --help # Show all options with examples
|
||||
```
|
||||
@@ -191,14 +204,16 @@ bind = SUPER, D, exec, owlry --profile dev
|
||||
bind = SUPER, M, exec, owlry --profile media
|
||||
```
|
||||
|
||||
Profiles can also be managed from the launcher itself — see [Settings Editor](#settings-editor).
|
||||
|
||||
### 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.
|
||||
dmenu mode is self-contained: it does not use the daemon and works without `owlryd` running.
|
||||
|
||||
```bash
|
||||
# Screenshot menu (execute selected command)
|
||||
# Screenshot menu
|
||||
printf '%s\n' \
|
||||
"grimblast --notify copy screen" \
|
||||
"grimblast --notify copy area" \
|
||||
@@ -217,9 +232,6 @@ 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.
|
||||
@@ -235,6 +247,26 @@ The `-p` / `--prompt` flag sets a custom label for the search input.
|
||||
| `Shift+Tab` | Cycle filter tabs (reverse) |
|
||||
| `Ctrl+1..9` | Toggle tab by position |
|
||||
|
||||
### Settings Editor
|
||||
|
||||
Type `:config` to browse and modify settings without editing files:
|
||||
|
||||
| Command | What it does |
|
||||
|---------|-------------|
|
||||
| `:config` | Show all setting categories |
|
||||
| `:config providers` | Toggle built-in providers on/off (calculator, converter, system, frecency) |
|
||||
| `:config theme` | Select color theme |
|
||||
| `:config engine` | Select web search engine |
|
||||
| `:config frecency` | Toggle frecency, set weight |
|
||||
| `:config fontsize 16` | Set font size (restart to apply) |
|
||||
| `:config profiles` | List profiles |
|
||||
| `:config profile create dev` | Create a new profile |
|
||||
| `:config profile dev modes` | Edit which modes a profile includes |
|
||||
|
||||
Changes are saved to `config.toml` immediately. Some settings (theme, frecency) take effect on the next search. Others (font size, dimensions) require a restart.
|
||||
|
||||
> **Note:** `:config providers` only covers built-in providers. To enable or disable plugins, use `owlry plugin enable/disable <name>` or set `disabled_plugins` in `[plugins]`.
|
||||
|
||||
### Search Prefixes
|
||||
|
||||
| Prefix | Provider | Example |
|
||||
@@ -251,6 +283,7 @@ The `-p` / `--prompt` flag sets a custom label for the search input.
|
||||
| `:calc` | Calculator | `:calc sqrt(16)` |
|
||||
| `:web` | Web search | `:web rust docs` |
|
||||
| `:uuctl` | systemd | `:uuctl docker` |
|
||||
| `:config` | Settings | `:config theme` |
|
||||
| `:tag:X` | Filter by tag | `:tag:development` |
|
||||
|
||||
### Trigger Prefixes
|
||||
@@ -259,6 +292,7 @@ The `-p` / `--prompt` flag sets a custom label for the search input.
|
||||
|---------|----------|---------|
|
||||
| `=` | Calculator | `= 5+3` |
|
||||
| `calc ` | Calculator | `calc sqrt(16)` |
|
||||
| `>` | Converter | `> 20 km to mi` |
|
||||
| `?` | Web search | `? rust programming` |
|
||||
| `web ` | Web search | `web linux tips` |
|
||||
| `/` | File search | `/ .bashrc` |
|
||||
@@ -278,6 +312,7 @@ Owlry follows the [XDG Base Directory Specification](https://specifications.free
|
||||
| `~/.local/share/owlry/frecency.json` | Usage history |
|
||||
|
||||
System locations:
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `/usr/lib/owlry/plugins/*.so` | Installed native plugins |
|
||||
@@ -292,35 +327,61 @@ mkdir -p ~/.config/owlry
|
||||
cp /usr/share/doc/owlry/config.example.toml ~/.config/owlry/config.toml
|
||||
```
|
||||
|
||||
Or configure from within the launcher: type `:config` to interactively change settings.
|
||||
|
||||
### Example Configuration
|
||||
|
||||
```toml
|
||||
[general]
|
||||
show_icons = true
|
||||
max_results = 10
|
||||
tabs = ["app", "cmd", "uuctl"]
|
||||
# terminal_command = "kitty" # Auto-detected
|
||||
# use_uwsm = false # Enable for systemd session integration
|
||||
max_results = 100
|
||||
tabs = ["app", "cmd", "uuctl"] # Provider tabs shown in the header bar
|
||||
# terminal_command = "kitty" # Auto-detected; overrides $TERMINAL and xdg-terminal-exec
|
||||
# use_uwsm = false # Enable for systemd session integration (uwsm app --)
|
||||
|
||||
[appearance]
|
||||
width = 850
|
||||
height = 650
|
||||
font_size = 14
|
||||
border_radius = 12
|
||||
# theme = "owl" # Or: catppuccin-mocha, nord, dracula, etc.
|
||||
# theme = "owl" # Or: catppuccin-mocha, nord, dracula, etc. (see Theming section)
|
||||
|
||||
[plugins]
|
||||
disabled = [] # Plugin IDs to disable, e.g., ["emoji", "pomodoro"]
|
||||
# Optional per-element color overrides — all fields are optional, unset inherits from theme
|
||||
# [appearance.colors]
|
||||
# background = "#1e1e2e"
|
||||
# background_secondary = "#313244"
|
||||
# border = "#45475a"
|
||||
# text = "#cdd6f4"
|
||||
# accent = "#cba6f7"
|
||||
# badge_app = "#a6e3a1" # All badge_* keys: app, cmd, clip, ssh, emoji, file,
|
||||
# badge_web = "#89dceb" # script, sys, uuctl, web, calc, bm, dmenu,
|
||||
# badge_media = "#f38ba8" # media, weather, pomo
|
||||
|
||||
[providers]
|
||||
applications = true # .desktop files
|
||||
commands = true # PATH executables
|
||||
calculator = true # Built-in math expressions (= or calc trigger)
|
||||
converter = true # Built-in unit/currency converter (> trigger)
|
||||
system = true # Built-in shutdown/reboot/lock actions
|
||||
frecency = true # Boost frequently used items
|
||||
frecency_weight = 0.3 # 0.0-1.0
|
||||
frecency_weight = 0.3 # 0.0 = disabled, 1.0 = strong boost
|
||||
|
||||
# Web search engine: google, duckduckgo, bing, startpage, brave, ecosia
|
||||
# Web search engine: google, duckduckgo, bing, startpage, searxng, brave, ecosia
|
||||
# Or a custom URL with a {query} placeholder: "https://example.com/search?q={query}"
|
||||
search_engine = "duckduckgo"
|
||||
|
||||
[plugins]
|
||||
# disabled_plugins = ["emoji", "pomodoro"] # Plugin IDs to disable
|
||||
# enabled_plugins = [] # Empty = all discovered plugins are loaded
|
||||
# registry_url = "https://..." # Custom plugin registry URL
|
||||
|
||||
# Sandboxing for Lua/Rune user plugins (~/.config/owlry/plugins/)
|
||||
# [plugins.sandbox]
|
||||
# allow_filesystem = false # Allow access outside plugin directory
|
||||
# allow_network = false # Allow outbound network requests
|
||||
# allow_commands = false # Allow shell command execution
|
||||
# memory_limit = 67108864 # Lua memory cap in bytes (default: 64 MB)
|
||||
|
||||
# Profiles: named sets of modes
|
||||
[profiles.dev]
|
||||
modes = ["app", "cmd", "ssh"]
|
||||
@@ -333,7 +394,7 @@ See `/usr/share/doc/owlry/config.example.toml` for all options with documentatio
|
||||
|
||||
## Plugin System
|
||||
|
||||
Owlry uses a modular plugin architecture. Plugins are loaded by the daemon (`owlry-core`) from:
|
||||
Owlry uses a modular plugin architecture. Plugins are loaded by the daemon from:
|
||||
|
||||
- `/usr/lib/owlry/plugins/*.so` — System plugins (AUR packages)
|
||||
- `~/.config/owlry/plugins/` — User plugins (requires `owlry-lua` or `owlry-rune`)
|
||||
@@ -344,9 +405,18 @@ Add plugin IDs to the disabled list in your config:
|
||||
|
||||
```toml
|
||||
[plugins]
|
||||
disabled = ["emoji", "pomodoro"]
|
||||
disabled_plugins = ["emoji", "pomodoro"]
|
||||
```
|
||||
|
||||
Or use the CLI:
|
||||
|
||||
```bash
|
||||
owlry plugin disable emoji
|
||||
owlry plugin enable emoji
|
||||
```
|
||||
|
||||
> **Note:** `:config providers` in the launcher only manages built-in providers (calculator, converter, system). Use `disabled_plugins` or `owlry plugin disable` for plugins.
|
||||
|
||||
### Plugin Management CLI
|
||||
|
||||
```bash
|
||||
@@ -402,12 +472,15 @@ See [docs/PLUGIN_DEVELOPMENT.md](docs/PLUGIN_DEVELOPMENT.md) for:
|
||||
| `tokyo-night` | Tokyo city lights |
|
||||
| `solarized-dark` | Precision colors |
|
||||
| `one-dark` | Atom's One Dark |
|
||||
| `apex-neon` | Neon cyberpunk |
|
||||
|
||||
```toml
|
||||
[appearance]
|
||||
theme = "catppuccin-mocha"
|
||||
```
|
||||
|
||||
Or select interactively: type `:config theme` in the launcher.
|
||||
|
||||
### Custom Theme
|
||||
|
||||
Create `~/.config/owlry/themes/mytheme.css`:
|
||||
@@ -435,18 +508,24 @@ Create `~/.config/owlry/themes/mytheme.css`:
|
||||
| `--owlry-text-secondary` | Muted text |
|
||||
| `--owlry-accent` | Accent color |
|
||||
| `--owlry-accent-bright` | Bright accent |
|
||||
| `--owlry-shadow` | Window shadow (default: none) |
|
||||
|
||||
## Architecture
|
||||
|
||||
Owlry uses a client/daemon split:
|
||||
|
||||
```
|
||||
owlry-core (daemon) owlry (GTK4 UI client)
|
||||
owlryd (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)
|
||||
├── Built-in providers ├── Renders results in GTK4 window
|
||||
│ ├── Applications (.desktop) ├── Handles keyboard input
|
||||
│ ├── Commands (PATH) ├── Toggle: second launch closes window
|
||||
│ ├── Calculator (math) └── dmenu mode (self-contained, no daemon)
|
||||
│ ├── Converter (units/currency)
|
||||
│ ├── System (power/session)
|
||||
│ └── Config editor (settings)
|
||||
├── Plugin loader
|
||||
│ ├── /usr/lib/owlry/plugins/*.so
|
||||
│ ├── /usr/lib/owlry/runtimes/
|
||||
│ └── ~/.config/owlry/plugins/
|
||||
├── Frecency tracking
|
||||
@@ -457,8 +536,6 @@ owlry-core (daemon) owlry (GTK4 UI client)
|
||||
|
||||
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
|
||||
|
||||
GNU General Public License v3.0 — see [LICENSE](LICENSE).
|
||||
|
||||
14
aur/owlry-core/.SRCINFO
Normal file
14
aur/owlry-core/.SRCINFO
Normal file
@@ -0,0 +1,14 @@
|
||||
pkgbase = owlry-core
|
||||
pkgdesc = Core daemon for the Owlry application launcher — manages plugins, providers, and search
|
||||
pkgver = 1.3.4
|
||||
pkgrel = 1
|
||||
url = https://somegit.dev/Owlibou/owlry
|
||||
arch = x86_64
|
||||
license = GPL-3.0-or-later
|
||||
makedepends = cargo
|
||||
depends = gcc-libs
|
||||
depends = openssl
|
||||
source = owlry-core-1.3.4.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-core-v1.3.4.tar.gz
|
||||
b2sums = 648171ce688761babb7ada9ec96cb248fab5563cc45599f660f21e166bfb4db689cff22b82f3a1f2ae256dd54fb3d3f4d5a8acaf6a728976d42ee511e1f25e5f
|
||||
|
||||
pkgname = owlry-core
|
||||
10
aur/owlry-core/.gitignore
vendored
Normal file
10
aur/owlry-core/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
*.pkg.tar.zst
|
||||
*.pkg.tar.zst-namcap.log
|
||||
*-namcap.log
|
||||
*-build.log
|
||||
*-check.log
|
||||
*-package.log
|
||||
*-prepare.log
|
||||
*.tar.gz
|
||||
src/
|
||||
pkg/
|
||||
41
aur/owlry-core/PKGBUILD
Normal file
41
aur/owlry-core/PKGBUILD
Normal file
@@ -0,0 +1,41 @@
|
||||
# Maintainer: vikingowl <christian@nachtigall.dev>
|
||||
pkgname=owlry-core
|
||||
pkgver=1.3.4
|
||||
pkgrel=1
|
||||
pkgdesc='Core daemon for the Owlry application launcher — manages plugins, providers, and search'
|
||||
arch=('x86_64')
|
||||
url='https://somegit.dev/Owlibou/owlry'
|
||||
license=('GPL-3.0-or-later')
|
||||
depends=('gcc-libs' 'openssl')
|
||||
makedepends=('cargo')
|
||||
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-core-v$pkgver.tar.gz")
|
||||
b2sums=('648171ce688761babb7ada9ec96cb248fab5563cc45599f660f21e166bfb4db689cff22b82f3a1f2ae256dd54fb3d3f4d5a8acaf6a728976d42ee511e1f25e5f')
|
||||
|
||||
prepare() {
|
||||
cd "owlry"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
|
||||
}
|
||||
|
||||
build() {
|
||||
cd "owlry"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
export CARGO_TARGET_DIR=target
|
||||
cargo build -p owlry-core --frozen --release
|
||||
}
|
||||
|
||||
check() {
|
||||
cd "owlry"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
export CARGO_TARGET_DIR=target
|
||||
cargo test -p owlry-core --frozen --lib
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "owlry"
|
||||
install -Dm755 "target/release/owlryd" "$pkgdir/usr/bin/owlryd"
|
||||
install -Dm644 "systemd/owlryd.service" "$pkgdir/usr/lib/systemd/user/owlryd.service"
|
||||
install -Dm644 "systemd/owlryd.socket" "$pkgdir/usr/lib/systemd/user/owlryd.socket"
|
||||
install -dm755 "$pkgdir/usr/lib/owlry/plugins"
|
||||
install -dm755 "$pkgdir/usr/lib/owlry/runtimes"
|
||||
}
|
||||
14
aur/owlry-lua/.SRCINFO
Normal file
14
aur/owlry-lua/.SRCINFO
Normal file
@@ -0,0 +1,14 @@
|
||||
pkgbase = owlry-lua
|
||||
pkgdesc = Lua scripting runtime for Owlry — enables user-created Lua plugins
|
||||
pkgver = 1.1.3
|
||||
pkgrel = 1
|
||||
url = https://somegit.dev/Owlibou/owlry
|
||||
arch = x86_64
|
||||
license = GPL-3.0-or-later
|
||||
makedepends = cargo
|
||||
depends = owlry-core
|
||||
depends = openssl
|
||||
source = owlry-lua-1.1.3.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-lua-v1.1.3.tar.gz
|
||||
b2sums = 648171ce688761babb7ada9ec96cb248fab5563cc45599f660f21e166bfb4db689cff22b82f3a1f2ae256dd54fb3d3f4d5a8acaf6a728976d42ee511e1f25e5f
|
||||
|
||||
pkgname = owlry-lua
|
||||
10
aur/owlry-lua/.gitignore
vendored
Normal file
10
aur/owlry-lua/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
*.pkg.tar.zst
|
||||
*.pkg.tar.zst-namcap.log
|
||||
*-namcap.log
|
||||
*-build.log
|
||||
*-check.log
|
||||
*-package.log
|
||||
*-prepare.log
|
||||
*.tar.gz
|
||||
src/
|
||||
pkg/
|
||||
40
aur/owlry-lua/PKGBUILD
Normal file
40
aur/owlry-lua/PKGBUILD
Normal file
@@ -0,0 +1,40 @@
|
||||
# Maintainer: vikingowl <christian@nachtigall.dev>
|
||||
pkgname=owlry-lua
|
||||
pkgver=1.1.3
|
||||
pkgrel=1
|
||||
pkgdesc="Lua scripting runtime for Owlry — enables user-created Lua plugins"
|
||||
arch=('x86_64')
|
||||
url="https://somegit.dev/Owlibou/owlry"
|
||||
license=('GPL-3.0-or-later')
|
||||
depends=('owlry-core' 'openssl')
|
||||
makedepends=('cargo')
|
||||
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-lua-v$pkgver.tar.gz")
|
||||
b2sums=('648171ce688761babb7ada9ec96cb248fab5563cc45599f660f21e166bfb4db689cff22b82f3a1f2ae256dd54fb3d3f4d5a8acaf6a728976d42ee511e1f25e5f')
|
||||
|
||||
_cratename=owlry-lua
|
||||
|
||||
prepare() {
|
||||
cd "owlry"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
|
||||
}
|
||||
|
||||
build() {
|
||||
cd "owlry"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
export CARGO_TARGET_DIR=target
|
||||
cargo build -p $_cratename --frozen --release
|
||||
}
|
||||
|
||||
check() {
|
||||
cd "owlry"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
export CARGO_TARGET_DIR=target
|
||||
cargo test -p $_cratename --frozen --lib
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "owlry"
|
||||
install -Dm755 "target/release/lib${_cratename//-/_}.so" \
|
||||
"$pkgdir/usr/lib/owlry/runtimes/liblua.so"
|
||||
}
|
||||
1
aur/owlry-meta-essentials
Submodule
1
aur/owlry-meta-essentials
Submodule
Submodule aur/owlry-meta-essentials added at ed91b61709
1
aur/owlry-meta-full
Submodule
1
aur/owlry-meta-full
Submodule
Submodule aur/owlry-meta-full added at 2115aa08f8
1
aur/owlry-meta-tools
Submodule
1
aur/owlry-meta-tools
Submodule
Submodule aur/owlry-meta-tools added at bc821ff47f
1
aur/owlry-meta-widgets
Submodule
1
aur/owlry-meta-widgets
Submodule
Submodule aur/owlry-meta-widgets added at 8ba6dd318c
14
aur/owlry-rune/.SRCINFO
Normal file
14
aur/owlry-rune/.SRCINFO
Normal file
@@ -0,0 +1,14 @@
|
||||
pkgbase = owlry-rune
|
||||
pkgdesc = Rune scripting runtime for Owlry — enables user-created Rune plugins
|
||||
pkgver = 1.1.4
|
||||
pkgrel = 1
|
||||
url = https://somegit.dev/Owlibou/owlry
|
||||
arch = x86_64
|
||||
license = GPL-3.0-or-later
|
||||
makedepends = cargo
|
||||
depends = owlry-core
|
||||
depends = openssl
|
||||
source = owlry-rune-1.1.4.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-rune-v1.1.4.tar.gz
|
||||
b2sums = 648171ce688761babb7ada9ec96cb248fab5563cc45599f660f21e166bfb4db689cff22b82f3a1f2ae256dd54fb3d3f4d5a8acaf6a728976d42ee511e1f25e5f
|
||||
|
||||
pkgname = owlry-rune
|
||||
10
aur/owlry-rune/.gitignore
vendored
Normal file
10
aur/owlry-rune/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
*.pkg.tar.zst
|
||||
*.pkg.tar.zst-namcap.log
|
||||
*-namcap.log
|
||||
*-build.log
|
||||
*-check.log
|
||||
*-package.log
|
||||
*-prepare.log
|
||||
*.tar.gz
|
||||
src/
|
||||
pkg/
|
||||
40
aur/owlry-rune/PKGBUILD
Normal file
40
aur/owlry-rune/PKGBUILD
Normal file
@@ -0,0 +1,40 @@
|
||||
# Maintainer: vikingowl <christian@nachtigall.dev>
|
||||
pkgname=owlry-rune
|
||||
pkgver=1.1.4
|
||||
pkgrel=1
|
||||
pkgdesc="Rune scripting runtime for Owlry — enables user-created Rune plugins"
|
||||
arch=('x86_64')
|
||||
url="https://somegit.dev/Owlibou/owlry"
|
||||
license=('GPL-3.0-or-later')
|
||||
depends=('owlry-core' 'openssl')
|
||||
makedepends=('cargo')
|
||||
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-rune-v$pkgver.tar.gz")
|
||||
b2sums=('648171ce688761babb7ada9ec96cb248fab5563cc45599f660f21e166bfb4db689cff22b82f3a1f2ae256dd54fb3d3f4d5a8acaf6a728976d42ee511e1f25e5f')
|
||||
|
||||
_cratename=owlry-rune
|
||||
|
||||
prepare() {
|
||||
cd "owlry"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
|
||||
}
|
||||
|
||||
build() {
|
||||
cd "owlry"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
export CARGO_TARGET_DIR=target
|
||||
cargo build -p $_cratename --frozen --release
|
||||
}
|
||||
|
||||
check() {
|
||||
cd "owlry"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
export CARGO_TARGET_DIR=target
|
||||
cargo test -p $_cratename --frozen --release
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "owlry"
|
||||
install -Dm755 "target/release/lib${_cratename//-/_}.so" \
|
||||
"$pkgdir/usr/lib/owlry/runtimes/librune.so"
|
||||
}
|
||||
34
aur/owlry/.SRCINFO
Normal file
34
aur/owlry/.SRCINFO
Normal file
@@ -0,0 +1,34 @@
|
||||
pkgbase = owlry
|
||||
pkgdesc = Lightweight Wayland application launcher with plugin support
|
||||
pkgver = 1.0.8
|
||||
pkgrel = 1
|
||||
url = https://somegit.dev/Owlibou/owlry
|
||||
arch = x86_64
|
||||
license = GPL-3.0-or-later
|
||||
makedepends = cargo
|
||||
depends = owlry-core
|
||||
depends = gcc-libs
|
||||
depends = gtk4
|
||||
depends = gtk4-layer-shell
|
||||
optdepends = cliphist: clipboard provider support
|
||||
optdepends = wl-clipboard: clipboard and emoji copy support
|
||||
optdepends = fd: fast file search
|
||||
optdepends = owlry-plugin-calculator: calculator provider
|
||||
optdepends = owlry-plugin-clipboard: clipboard provider
|
||||
optdepends = owlry-plugin-emoji: emoji picker
|
||||
optdepends = owlry-plugin-bookmarks: browser bookmarks
|
||||
optdepends = owlry-plugin-ssh: SSH host launcher
|
||||
optdepends = owlry-plugin-scripts: custom scripts provider
|
||||
optdepends = owlry-plugin-system: system actions (shutdown, reboot, etc.)
|
||||
optdepends = owlry-plugin-websearch: web search provider
|
||||
optdepends = owlry-plugin-filesearch: file search provider
|
||||
optdepends = owlry-plugin-systemd: systemd service management
|
||||
optdepends = owlry-plugin-weather: weather widget
|
||||
optdepends = owlry-plugin-media: media player controls
|
||||
optdepends = owlry-plugin-pomodoro: pomodoro timer widget
|
||||
optdepends = owlry-lua: Lua runtime for user plugins
|
||||
optdepends = owlry-rune: Rune runtime for user plugins
|
||||
source = owlry-1.0.8.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-v1.0.8.tar.gz
|
||||
b2sums = 648171ce688761babb7ada9ec96cb248fab5563cc45599f660f21e166bfb4db689cff22b82f3a1f2ae256dd54fb3d3f4d5a8acaf6a728976d42ee511e1f25e5f
|
||||
|
||||
pkgname = owlry
|
||||
10
aur/owlry/.gitignore
vendored
Normal file
10
aur/owlry/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
*.pkg.tar.zst
|
||||
*.pkg.tar.zst-namcap.log
|
||||
*-namcap.log
|
||||
*-build.log
|
||||
*-check.log
|
||||
*-package.log
|
||||
*-prepare.log
|
||||
*.tar.gz
|
||||
src/
|
||||
pkg/
|
||||
76
aur/owlry/PKGBUILD
Normal file
76
aur/owlry/PKGBUILD
Normal file
@@ -0,0 +1,76 @@
|
||||
# Maintainer: vikingowl <christian@nachtigall.dev>
|
||||
pkgname=owlry
|
||||
pkgver=1.0.8
|
||||
pkgrel=1
|
||||
pkgdesc="Lightweight Wayland application launcher with plugin support"
|
||||
arch=('x86_64')
|
||||
url="https://somegit.dev/Owlibou/owlry"
|
||||
license=('GPL-3.0-or-later')
|
||||
depends=('owlry-core' 'gcc-libs' 'gtk4' 'gtk4-layer-shell')
|
||||
makedepends=('cargo')
|
||||
optdepends=(
|
||||
'cliphist: clipboard provider support'
|
||||
'wl-clipboard: clipboard and emoji copy support'
|
||||
'fd: fast file search'
|
||||
'owlry-plugin-calculator: calculator provider'
|
||||
'owlry-plugin-clipboard: clipboard provider'
|
||||
'owlry-plugin-emoji: emoji picker'
|
||||
'owlry-plugin-bookmarks: browser bookmarks'
|
||||
'owlry-plugin-ssh: SSH host launcher'
|
||||
'owlry-plugin-scripts: custom scripts provider'
|
||||
'owlry-plugin-system: system actions (shutdown, reboot, etc.)'
|
||||
'owlry-plugin-websearch: web search provider'
|
||||
'owlry-plugin-filesearch: file search provider'
|
||||
'owlry-plugin-systemd: systemd service management'
|
||||
'owlry-plugin-weather: weather widget'
|
||||
'owlry-plugin-media: media player controls'
|
||||
'owlry-plugin-pomodoro: pomodoro timer widget'
|
||||
'owlry-lua: Lua runtime for user plugins'
|
||||
'owlry-rune: Rune runtime for user plugins'
|
||||
)
|
||||
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-v$pkgver.tar.gz")
|
||||
b2sums=('648171ce688761babb7ada9ec96cb248fab5563cc45599f660f21e166bfb4db689cff22b82f3a1f2ae256dd54fb3d3f4d5a8acaf6a728976d42ee511e1f25e5f')
|
||||
|
||||
prepare() {
|
||||
cd "owlry"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
|
||||
}
|
||||
|
||||
build() {
|
||||
cd "owlry"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
export CARGO_TARGET_DIR=target
|
||||
# Build only the core binary without embedded Lua (Lua runtime is separate package)
|
||||
cargo build -p owlry --frozen --release --no-default-features
|
||||
}
|
||||
|
||||
check() {
|
||||
cd "owlry"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
export CARGO_TARGET_DIR=target
|
||||
cargo test -p owlry --frozen --no-default-features
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "owlry"
|
||||
|
||||
# Core binary
|
||||
install -Dm755 "target/release/$pkgname" "$pkgdir/usr/bin/$pkgname"
|
||||
|
||||
# Documentation
|
||||
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||
|
||||
# Example configuration files
|
||||
install -Dm644 data/config.example.toml "$pkgdir/usr/share/doc/$pkgname/config.example.toml"
|
||||
install -Dm644 data/style.example.css "$pkgdir/usr/share/doc/$pkgname/style.example.css"
|
||||
install -Dm755 data/scripts/example.sh "$pkgdir/usr/share/doc/$pkgname/scripts/example.sh"
|
||||
|
||||
# Install themes
|
||||
install -d "$pkgdir/usr/share/$pkgname/themes"
|
||||
install -Dm644 data/themes/*.css "$pkgdir/usr/share/$pkgname/themes/"
|
||||
|
||||
# Example plugins (for user plugin development)
|
||||
install -d "$pkgdir/usr/share/$pkgname/examples/plugins"
|
||||
cp -r examples/plugins/* "$pkgdir/usr/share/$pkgname/examples/plugins/"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "owlry-core"
|
||||
version = "1.1.0"
|
||||
version = "1.3.4"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
@@ -12,7 +12,7 @@ name = "owlry_core"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "owlry-core"
|
||||
name = "owlryd"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
@@ -30,6 +30,7 @@ semver = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "0.8"
|
||||
fs2 = "0.4"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
dirs = "5"
|
||||
|
||||
@@ -41,22 +42,24 @@ notify = "7"
|
||||
notify-debouncer-mini = "0.5"
|
||||
|
||||
# Signal handling
|
||||
ctrlc = { version = "3", features = ["termination"] }
|
||||
signal-hook = "0.3"
|
||||
|
||||
# Logging & notifications
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
notify-rust = "4"
|
||||
|
||||
# Built-in providers
|
||||
meval = "0.2"
|
||||
reqwest = { version = "0.13", default-features = false, features = ["native-tls", "json", "blocking"] }
|
||||
|
||||
# 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"]
|
||||
lua = ["dep:mlua"]
|
||||
dev-logging = []
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use fs2::FileExt;
|
||||
use log::{debug, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::paths;
|
||||
|
||||
@@ -33,6 +33,10 @@ pub struct Config {
|
||||
pub plugins: PluginsConfig,
|
||||
#[serde(default)]
|
||||
pub profiles: HashMap<String, ProfileConfig>,
|
||||
/// Per-plugin configuration tables.
|
||||
/// Defined as `[plugin_config.<plugin_name>]` in config.toml.
|
||||
#[serde(default)]
|
||||
pub plugin_config: HashMap<String, toml::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -158,79 +162,26 @@ pub struct ProvidersConfig {
|
||||
pub applications: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub commands: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub uuctl: bool,
|
||||
/// Enable calculator provider (= expression or calc expression)
|
||||
/// Enable built-in calculator provider (= or calc trigger)
|
||||
#[serde(default = "default_true")]
|
||||
pub calculator: bool,
|
||||
/// Enable built-in unit/currency converter (> trigger)
|
||||
#[serde(default = "default_true")]
|
||||
pub converter: bool,
|
||||
/// Enable built-in system actions (shutdown, reboot, lock, etc.)
|
||||
#[serde(default = "default_true")]
|
||||
pub system: bool,
|
||||
/// Enable frecency-based result ranking
|
||||
#[serde(default = "default_true")]
|
||||
pub frecency: bool,
|
||||
/// Weight for frecency boost (0.0 = disabled, 1.0 = strong boost)
|
||||
#[serde(default = "default_frecency_weight")]
|
||||
pub frecency_weight: f64,
|
||||
/// Enable web search provider (? query or web query)
|
||||
#[serde(default = "default_true")]
|
||||
pub websearch: bool,
|
||||
/// Search engine for web search
|
||||
/// Search engine for web search (used by owlry-plugin-websearch)
|
||||
/// Options: google, duckduckgo, bing, startpage, searxng, brave, ecosia
|
||||
/// Or custom URL with {query} placeholder
|
||||
/// Or a custom URL with a {query} placeholder
|
||||
#[serde(default = "default_search_engine")]
|
||||
pub search_engine: String,
|
||||
/// Enable system commands (shutdown, reboot, etc.)
|
||||
#[serde(default = "default_true")]
|
||||
pub system: bool,
|
||||
/// Enable SSH connections from ~/.ssh/config
|
||||
#[serde(default = "default_true")]
|
||||
pub ssh: bool,
|
||||
/// Enable clipboard history (requires cliphist)
|
||||
#[serde(default = "default_true")]
|
||||
pub clipboard: bool,
|
||||
/// Enable browser bookmarks
|
||||
#[serde(default = "default_true")]
|
||||
pub bookmarks: bool,
|
||||
/// Enable emoji picker
|
||||
#[serde(default = "default_true")]
|
||||
pub emoji: bool,
|
||||
/// Enable custom scripts from ~/.config/owlry/scripts/
|
||||
#[serde(default = "default_true")]
|
||||
pub scripts: bool,
|
||||
/// Enable file search (requires fd or locate)
|
||||
#[serde(default = "default_true")]
|
||||
pub files: bool,
|
||||
|
||||
// ─── Widget Providers ───────────────────────────────────────────────
|
||||
/// Enable MPRIS media player widget
|
||||
#[serde(default = "default_true")]
|
||||
pub media: bool,
|
||||
|
||||
/// Enable weather widget
|
||||
#[serde(default)]
|
||||
pub weather: bool,
|
||||
|
||||
/// Weather provider: wttr.in (default), openweathermap, open-meteo
|
||||
#[serde(default = "default_weather_provider")]
|
||||
pub weather_provider: String,
|
||||
|
||||
/// API key for weather services that require it (e.g., OpenWeatherMap)
|
||||
#[serde(default)]
|
||||
pub weather_api_key: Option<String>,
|
||||
|
||||
/// Location for weather (city name or coordinates)
|
||||
#[serde(default)]
|
||||
pub weather_location: Option<String>,
|
||||
|
||||
/// Enable pomodoro timer widget
|
||||
#[serde(default)]
|
||||
pub pomodoro: bool,
|
||||
|
||||
/// Pomodoro work duration in minutes
|
||||
#[serde(default = "default_pomodoro_work")]
|
||||
pub pomodoro_work_mins: u32,
|
||||
|
||||
/// Pomodoro break duration in minutes
|
||||
#[serde(default = "default_pomodoro_break")]
|
||||
pub pomodoro_break_mins: u32,
|
||||
}
|
||||
|
||||
impl Default for ProvidersConfig {
|
||||
@@ -238,27 +189,12 @@ impl Default for ProvidersConfig {
|
||||
Self {
|
||||
applications: true,
|
||||
commands: true,
|
||||
uuctl: true,
|
||||
calculator: true,
|
||||
converter: true,
|
||||
system: true,
|
||||
frecency: true,
|
||||
frecency_weight: 0.3,
|
||||
websearch: true,
|
||||
search_engine: "duckduckgo".to_string(),
|
||||
system: true,
|
||||
ssh: true,
|
||||
clipboard: true,
|
||||
bookmarks: true,
|
||||
emoji: true,
|
||||
scripts: true,
|
||||
files: true,
|
||||
media: true,
|
||||
weather: false,
|
||||
weather_provider: "wttr.in".to_string(),
|
||||
weather_api_key: None,
|
||||
weather_location: Some("Berlin".to_string()),
|
||||
pomodoro: false,
|
||||
pomodoro_work_mins: 25,
|
||||
pomodoro_break_mins: 5,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -284,10 +220,6 @@ pub struct PluginsConfig {
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
|
||||
/// List of plugin IDs to enable (empty = all discovered plugins)
|
||||
#[serde(default)]
|
||||
pub enabled_plugins: Vec<String>,
|
||||
|
||||
/// List of plugin IDs to explicitly disable
|
||||
#[serde(default)]
|
||||
pub disabled_plugins: Vec<String>,
|
||||
@@ -301,11 +233,6 @@ pub struct PluginsConfig {
|
||||
#[serde(default)]
|
||||
pub registry_url: Option<String>,
|
||||
|
||||
/// Per-plugin configuration tables
|
||||
/// Accessed via `[plugins.<plugin_name>]` sections in config.toml
|
||||
/// Each plugin can define its own config schema
|
||||
#[serde(flatten)]
|
||||
pub plugin_configs: HashMap<String, toml::Value>,
|
||||
}
|
||||
|
||||
/// Sandbox settings for plugin security
|
||||
@@ -332,43 +259,13 @@ impl Default for PluginsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
enabled_plugins: Vec::new(),
|
||||
disabled_plugins: Vec::new(),
|
||||
sandbox: SandboxConfig::default(),
|
||||
registry_url: None,
|
||||
plugin_configs: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginsConfig {
|
||||
/// Get configuration for a specific plugin by name
|
||||
///
|
||||
/// Returns the plugin's config table if it exists in `[plugins.<name>]`
|
||||
#[allow(dead_code)]
|
||||
pub fn get_plugin_config(&self, plugin_name: &str) -> Option<&toml::Value> {
|
||||
self.plugin_configs.get(plugin_name)
|
||||
}
|
||||
|
||||
/// Get a string value from a plugin's config
|
||||
#[allow(dead_code)]
|
||||
pub fn get_plugin_string(&self, plugin_name: &str, key: &str) -> Option<&str> {
|
||||
self.plugin_configs.get(plugin_name)?.get(key)?.as_str()
|
||||
}
|
||||
|
||||
/// Get an integer value from a plugin's config
|
||||
#[allow(dead_code)]
|
||||
pub fn get_plugin_int(&self, plugin_name: &str, key: &str) -> Option<i64> {
|
||||
self.plugin_configs.get(plugin_name)?.get(key)?.as_integer()
|
||||
}
|
||||
|
||||
/// Get a boolean value from a plugin's config
|
||||
#[allow(dead_code)]
|
||||
pub fn get_plugin_bool(&self, plugin_name: &str, key: &str) -> Option<bool> {
|
||||
self.plugin_configs.get(plugin_name)?.get(key)?.as_bool()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SandboxConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -396,18 +293,6 @@ fn default_frecency_weight() -> f64 {
|
||||
0.3
|
||||
}
|
||||
|
||||
fn default_weather_provider() -> String {
|
||||
"wttr.in".to_string()
|
||||
}
|
||||
|
||||
fn default_pomodoro_work() -> u32 {
|
||||
25
|
||||
}
|
||||
|
||||
fn default_pomodoro_break() -> u32 {
|
||||
5
|
||||
}
|
||||
|
||||
/// Detect the best available terminal emulator
|
||||
/// Fallback chain:
|
||||
/// 1. $TERMINAL env var (user's explicit preference)
|
||||
@@ -522,22 +407,65 @@ fn detect_de_terminal() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Check if a command exists in PATH
|
||||
/// Check if a command exists in PATH (in-process, no subprocess spawning)
|
||||
fn command_exists(cmd: &str) -> bool {
|
||||
Command::new("which")
|
||||
.arg(cmd)
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
std::env::var_os("PATH")
|
||||
.map(|paths| {
|
||||
std::env::split_paths(&paths).any(|dir| {
|
||||
let full = dir.join(cmd);
|
||||
full.is_file()
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
// Note: Config derives Default via #[derive(Default)] - all sub-structs have impl Default
|
||||
|
||||
/// Extract leading comment lines (lines beginning with `#`) from a TOML file's content.
|
||||
/// Stops at the first non-comment, non-empty line.
|
||||
fn extract_header_comments(content: &str) -> String {
|
||||
let mut header = String::new();
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with('#') || trimmed.is_empty() {
|
||||
header.push_str(line);
|
||||
header.push('\n');
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
header
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn config_path() -> Option<PathBuf> {
|
||||
paths::config_file()
|
||||
}
|
||||
|
||||
/// Get configuration table for a plugin by name.
|
||||
#[allow(dead_code)]
|
||||
pub fn get_plugin_config(&self, plugin_name: &str) -> Option<&toml::Value> {
|
||||
self.plugin_config.get(plugin_name)
|
||||
}
|
||||
|
||||
/// Get a string value from a plugin's config.
|
||||
#[allow(dead_code)]
|
||||
pub fn get_plugin_string(&self, plugin_name: &str, key: &str) -> Option<&str> {
|
||||
self.plugin_config.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_config.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_config.get(plugin_name)?.get(key)?.as_bool()
|
||||
}
|
||||
|
||||
pub fn load_or_default() -> Self {
|
||||
Self::load().unwrap_or_else(|e| {
|
||||
warn!("Failed to load config: {}, using defaults", e);
|
||||
@@ -553,8 +481,27 @@ impl Config {
|
||||
Self::default()
|
||||
} else {
|
||||
let content = std::fs::read_to_string(&path)?;
|
||||
let config: Config = toml::from_str(&content)?;
|
||||
let mut config: Config = toml::from_str(&content)?;
|
||||
info!("Loaded config from {:?}", path);
|
||||
// Migrate legacy [plugins.<name>] entries to [plugin_config.<name>].
|
||||
// Known PluginsConfig fields are excluded from migration.
|
||||
const KNOWN_PLUGINS_KEYS: &[&str] =
|
||||
&["enabled", "disabled_plugins", "sandbox", "registry_url"];
|
||||
if let Ok(raw) = toml::from_str::<toml::Value>(&content)
|
||||
&& let Some(plugins_table) = raw.get("plugins").and_then(|v| v.as_table())
|
||||
{
|
||||
for (key, value) in plugins_table {
|
||||
if !KNOWN_PLUGINS_KEYS.contains(&key.as_str())
|
||||
&& !config.plugin_config.contains_key(key)
|
||||
{
|
||||
warn!(
|
||||
"Config: [plugins.{}] is deprecated; move to [plugin_config.{}]",
|
||||
key, key
|
||||
);
|
||||
config.plugin_config.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
config
|
||||
};
|
||||
|
||||
@@ -579,15 +526,61 @@ impl Config {
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let path = Self::config_path().ok_or("Could not determine config path")?;
|
||||
|
||||
paths::ensure_parent_dir(&path)?;
|
||||
|
||||
let content = toml::to_string_pretty(self)?;
|
||||
std::fs::write(&path, content)?;
|
||||
// Acquire an exclusive advisory lock via a sibling lock file.
|
||||
// Concurrent writers (e.g. two `owlry plugin enable` invocations) will
|
||||
// block here until the first one finishes, preventing interleaved writes.
|
||||
let lock_path = path.with_extension("toml.lock");
|
||||
let lock_file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(false) // lock files are never written to; don't clobber existing
|
||||
.open(&lock_path)?;
|
||||
lock_file.lock_exclusive()?;
|
||||
|
||||
// Preserve any leading comment block (e.g. user docs / generated header).
|
||||
let header = if path.exists() {
|
||||
let existing = std::fs::read_to_string(&path)?;
|
||||
extract_header_comments(&existing)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let body = toml::to_string_pretty(self)?;
|
||||
let content = if header.is_empty() {
|
||||
body
|
||||
} else {
|
||||
format!("{}\n{}", header.trim_end(), body)
|
||||
};
|
||||
|
||||
// Atomic write: write to a sibling temp file, then rename over the target.
|
||||
// rename(2) is atomic on POSIX — readers always see either the old or new file.
|
||||
let tmp_path = path.with_extension("toml.tmp");
|
||||
std::fs::write(&tmp_path, &content)?;
|
||||
std::fs::rename(&tmp_path, &path)?;
|
||||
|
||||
// Lock is released when lock_file is dropped here.
|
||||
drop(lock_file);
|
||||
|
||||
info!("Saved config to {:?}", path);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn command_exists_finds_sh() {
|
||||
// /bin/sh exists on every Unix system
|
||||
assert!(super::command_exists("sh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_exists_rejects_nonexistent() {
|
||||
assert!(!super::command_exists("owlry_nonexistent_binary_abc123"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,10 @@ impl Default for FrecencyData {
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_ENTRIES: usize = 5000;
|
||||
const PRUNE_AGE_DAYS: i64 = 180;
|
||||
const MIN_LAUNCHES_TO_KEEP: u32 = 3;
|
||||
|
||||
/// Frecency store for tracking and boosting recently/frequently used items
|
||||
pub struct FrecencyStore {
|
||||
data: FrecencyData,
|
||||
@@ -44,10 +48,49 @@ impl FrecencyStore {
|
||||
|
||||
info!("Frecency store loaded with {} entries", data.entries.len());
|
||||
|
||||
Self {
|
||||
let mut store = Self {
|
||||
data,
|
||||
path,
|
||||
dirty: false,
|
||||
};
|
||||
store.prune();
|
||||
store
|
||||
}
|
||||
|
||||
/// Remove stale low-usage entries and enforce the hard cap.
|
||||
///
|
||||
/// Entries older than `PRUNE_AGE_DAYS` with fewer than `MIN_LAUNCHES_TO_KEEP`
|
||||
/// launches are removed. After age-based pruning, entries are sorted by score
|
||||
/// (descending) and the list is truncated to `MAX_ENTRIES`.
|
||||
fn prune(&mut self) {
|
||||
let now = Utc::now();
|
||||
let cutoff = now - chrono::Duration::days(PRUNE_AGE_DAYS);
|
||||
|
||||
let before = self.data.entries.len();
|
||||
self.data.entries.retain(|_, e| {
|
||||
e.last_launch > cutoff || e.launch_count >= MIN_LAUNCHES_TO_KEEP
|
||||
});
|
||||
|
||||
if self.data.entries.len() > MAX_ENTRIES {
|
||||
// Sort by score descending and keep the top MAX_ENTRIES
|
||||
let mut scored: Vec<(String, f64)> = self
|
||||
.data
|
||||
.entries
|
||||
.iter()
|
||||
.map(|(k, e)| {
|
||||
(k.clone(), Self::calculate_frecency_at(e.launch_count, e.last_launch, now))
|
||||
})
|
||||
.collect();
|
||||
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let keep: std::collections::HashSet<String> =
|
||||
scored.into_iter().take(MAX_ENTRIES).map(|(k, _)| k).collect();
|
||||
self.data.entries.retain(|k, _| keep.contains(k));
|
||||
}
|
||||
|
||||
let removed = before - self.data.entries.len();
|
||||
if removed > 0 {
|
||||
info!("Frecency: pruned {} stale entries ({} remaining)", removed, self.data.entries.len());
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,11 +158,6 @@ impl FrecencyStore {
|
||||
"Recorded launch for '{}': count={}, last={}",
|
||||
item_id, entry.launch_count, entry.last_launch
|
||||
);
|
||||
|
||||
// Auto-save after recording
|
||||
if let Err(e) = self.save() {
|
||||
warn!("Failed to save frecency data: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate frecency score for an item
|
||||
@@ -131,23 +169,36 @@ impl FrecencyStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate frecency score using a pre-sampled timestamp.
|
||||
/// Use this in hot loops to avoid repeated Utc::now() syscalls.
|
||||
pub fn get_score_at(&self, item_id: &str, now: DateTime<Utc>) -> f64 {
|
||||
match self.data.entries.get(item_id) {
|
||||
Some(entry) => Self::calculate_frecency_at(entry.launch_count, entry.last_launch, now),
|
||||
None => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate frecency using Firefox-style algorithm
|
||||
fn calculate_frecency(launch_count: u32, last_launch: DateTime<Utc>) -> f64 {
|
||||
let now = Utc::now();
|
||||
Self::calculate_frecency_at(launch_count, last_launch, now)
|
||||
}
|
||||
|
||||
/// Calculate frecency using a caller-provided timestamp.
|
||||
fn calculate_frecency_at(launch_count: u32, last_launch: DateTime<Utc>, now: DateTime<Utc>) -> f64 {
|
||||
let age = now.signed_duration_since(last_launch);
|
||||
let age_days = age.num_hours() as f64 / 24.0;
|
||||
|
||||
// Recency weight based on how recently the item was used
|
||||
let recency_weight = if age_days < 1.0 {
|
||||
100.0 // Today
|
||||
100.0
|
||||
} else if age_days < 7.0 {
|
||||
70.0 // This week
|
||||
70.0
|
||||
} else if age_days < 30.0 {
|
||||
50.0 // This month
|
||||
50.0
|
||||
} else if age_days < 90.0 {
|
||||
30.0 // This quarter
|
||||
30.0
|
||||
} else {
|
||||
10.0 // Older
|
||||
10.0
|
||||
};
|
||||
|
||||
launch_count as f64 * recency_weight
|
||||
@@ -206,6 +257,32 @@ mod tests {
|
||||
assert!(score_month < score_week);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_score_at_matches_get_score() {
|
||||
let mut store = FrecencyStore {
|
||||
data: FrecencyData {
|
||||
version: 1,
|
||||
entries: HashMap::new(),
|
||||
},
|
||||
path: PathBuf::from("/dev/null"),
|
||||
dirty: false,
|
||||
};
|
||||
store.data.entries.insert(
|
||||
"test".to_string(),
|
||||
FrecencyEntry {
|
||||
launch_count: 5,
|
||||
last_launch: Utc::now(),
|
||||
},
|
||||
);
|
||||
|
||||
let now = Utc::now();
|
||||
let score_at = store.get_score_at("test", now);
|
||||
let score = store.get_score("test");
|
||||
|
||||
// Both should be very close (same timestamp, within rounding)
|
||||
assert!((score_at - score).abs() < 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_launch_count_matters() {
|
||||
let now = Utc::now();
|
||||
@@ -216,4 +293,18 @@ mod tests {
|
||||
assert!(score_many > score_few);
|
||||
assert!((score_many / score_few - 10.0).abs() < 0.1); // Should be ~10x
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_launch_sets_dirty_without_saving() {
|
||||
let mut store = FrecencyStore {
|
||||
data: FrecencyData::default(),
|
||||
path: PathBuf::from("/dev/null"),
|
||||
dirty: false,
|
||||
};
|
||||
|
||||
store.record_launch("test-item");
|
||||
|
||||
assert!(store.dirty, "record_launch should set dirty flag");
|
||||
assert_eq!(store.data.entries["test-item"].launch_count, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,11 +26,17 @@ pub struct ParsedQuery {
|
||||
}
|
||||
|
||||
impl ProviderFilter {
|
||||
/// Create filter from CLI args and config
|
||||
/// Create filter from CLI args and config.
|
||||
///
|
||||
/// `tabs` is `general.tabs` from config and drives which provider tabs are
|
||||
/// shown in the UI when no explicit CLI mode is active. It has no effect on
|
||||
/// query routing: when no CLI mode is set, `accept_all=true` causes
|
||||
/// `is_active()` to return `true` for every provider regardless.
|
||||
pub fn new(
|
||||
cli_mode: Option<ProviderType>,
|
||||
cli_providers: Option<Vec<ProviderType>>,
|
||||
config_providers: &ProvidersConfig,
|
||||
tabs: &[String],
|
||||
) -> Self {
|
||||
let accept_all = cli_mode.is_none() && cli_providers.is_none();
|
||||
|
||||
@@ -41,50 +47,23 @@ impl ProviderFilter {
|
||||
// --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
|
||||
// No CLI restriction: accept_all=true, so is_active() returns true for
|
||||
// everything. Build the enabled set only for UI tab display, driven by
|
||||
// general.tabs. Falls back to Application + Command if tabs is empty.
|
||||
let mut set: HashSet<ProviderType> = tabs
|
||||
.iter()
|
||||
.map(|s| Self::mode_string_to_provider_type(s))
|
||||
.collect();
|
||||
if set.is_empty() {
|
||||
set.insert(ProviderType::Application);
|
||||
if config_providers.applications {
|
||||
set.insert(ProviderType::Application);
|
||||
}
|
||||
if config_providers.commands {
|
||||
set.insert(ProviderType::Command);
|
||||
}
|
||||
if set.is_empty() {
|
||||
set.insert(ProviderType::Application);
|
||||
}
|
||||
}
|
||||
set
|
||||
};
|
||||
@@ -114,7 +93,8 @@ impl ProviderFilter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle a provider on/off
|
||||
/// Toggle a provider on/off. Clears accept_all so the enabled set is
|
||||
/// actually used for routing — use restore_all_mode() to go back to All.
|
||||
pub fn toggle(&mut self, provider: ProviderType) {
|
||||
if self.enabled.contains(&provider) {
|
||||
self.enabled.remove(&provider);
|
||||
@@ -137,6 +117,7 @@ impl ProviderFilter {
|
||||
provider_debug, self.enabled
|
||||
);
|
||||
}
|
||||
self.accept_all = false;
|
||||
}
|
||||
|
||||
/// Enable a specific provider
|
||||
@@ -156,6 +137,12 @@ impl ProviderFilter {
|
||||
pub fn set_single_mode(&mut self, provider: ProviderType) {
|
||||
self.enabled.clear();
|
||||
self.enabled.insert(provider);
|
||||
self.accept_all = false;
|
||||
}
|
||||
|
||||
/// Restore accept-all mode (used when cycling back to the "All" tab).
|
||||
pub fn restore_all_mode(&mut self) {
|
||||
self.accept_all = true;
|
||||
}
|
||||
|
||||
/// Set prefix mode (from :app, :cmd, etc.)
|
||||
@@ -229,43 +216,48 @@ impl ProviderFilter {
|
||||
}
|
||||
}
|
||||
|
||||
// Core provider prefixes
|
||||
// Core prefixes — each entry is tried as ":name " (full) and ":name" (partial)
|
||||
let core_prefixes: &[(&str, ProviderType)] = &[
|
||||
(":app ", ProviderType::Application),
|
||||
(":apps ", ProviderType::Application),
|
||||
(":cmd ", ProviderType::Command),
|
||||
(":command ", ProviderType::Command),
|
||||
("app", ProviderType::Application),
|
||||
("apps", ProviderType::Application),
|
||||
("cmd", ProviderType::Command),
|
||||
("command", ProviderType::Command),
|
||||
];
|
||||
|
||||
// Plugin provider prefixes - mapped to Plugin(type_id)
|
||||
// Plugin prefixes — each entry maps to a 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"),
|
||||
("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"),
|
||||
("config", "config"),
|
||||
("settings", "config"),
|
||||
("conv", "conv"),
|
||||
("converter", "conv"),
|
||||
];
|
||||
|
||||
// Check core prefixes
|
||||
for (prefix_str, provider) in core_prefixes {
|
||||
if let Some(rest) = trimmed.strip_prefix(prefix_str) {
|
||||
// Single-pass: try each core prefix as both full (":name query") and partial (":name")
|
||||
for (name, provider) in core_prefixes {
|
||||
let with_space = format!(":{} ", name);
|
||||
if let Some(rest) = trimmed.strip_prefix(with_space.as_str()) {
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"[Filter] parse_query({:?}) -> prefix={:?}, query={:?}",
|
||||
@@ -277,60 +269,8 @@ impl ProviderFilter {
|
||||
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 {
|
||||
let exact = format!(":{}", name);
|
||||
if trimmed == exact {
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"[Filter] parse_query({:?}) -> partial prefix {:?}",
|
||||
@@ -344,8 +284,24 @@ impl ProviderFilter {
|
||||
}
|
||||
}
|
||||
|
||||
for (prefix_str, type_id) in partial_plugin {
|
||||
if trimmed == *prefix_str {
|
||||
// Single-pass: try each plugin prefix as both full and partial
|
||||
for (name, type_id) in plugin_prefixes {
|
||||
let with_space = format!(":{} ", name);
|
||||
if let Some(rest) = trimmed.strip_prefix(with_space.as_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(),
|
||||
};
|
||||
}
|
||||
let exact = format!(":{}", name);
|
||||
if trimmed == exact {
|
||||
let provider = ProviderType::Plugin(type_id.to_string());
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
|
||||
@@ -24,6 +24,8 @@ pub enum Request {
|
||||
PluginAction {
|
||||
command: String,
|
||||
},
|
||||
/// Query the daemon's plugin registry (native plugins + suppressed entries).
|
||||
PluginList,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
@@ -32,10 +34,30 @@ pub enum Response {
|
||||
Results { items: Vec<ResultItem> },
|
||||
Providers { list: Vec<ProviderDesc> },
|
||||
SubmenuItems { items: Vec<ResultItem> },
|
||||
PluginList { entries: Vec<PluginEntry> },
|
||||
Ack,
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
/// Registry entry for a loaded or suppressed plugin (native plugins only).
|
||||
/// Script plugins are tracked separately via filesystem discovery.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PluginEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
/// Plugin runtime type: "native", "builtin"
|
||||
pub runtime: String,
|
||||
/// Load status: "active" or "suppressed"
|
||||
pub status: String,
|
||||
/// Human-readable detail for non-active status (e.g. suppression reason)
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub status_detail: String,
|
||||
/// Provider type IDs registered by this plugin
|
||||
#[serde(default)]
|
||||
pub providers: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ResultItem {
|
||||
pub id: String,
|
||||
@@ -50,6 +72,14 @@ pub struct ResultItem {
|
||||
pub terminal: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub tags: Vec<String>,
|
||||
/// Item trust level: "core", "native_plugin", or "script_plugin".
|
||||
/// Defaults to "core" when absent (backwards-compatible with old daemons).
|
||||
#[serde(default = "default_source")]
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
fn default_source() -> String {
|
||||
"core".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use log::{info, warn};
|
||||
use log::info;
|
||||
|
||||
use owlry_core::paths;
|
||||
use owlry_core::server::Server;
|
||||
@@ -7,7 +7,7 @@ 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...");
|
||||
info!("Starting owlryd daemon...");
|
||||
|
||||
// Ensure the socket parent directory exists
|
||||
if let Err(e) = paths::ensure_parent_dir(&sock) {
|
||||
@@ -18,19 +18,13 @@ fn main() {
|
||||
let server = match Server::bind(&sock) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to start owlry-core: {e}");
|
||||
eprintln!("Failed to start owlryd: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Graceful shutdown on SIGTERM/SIGINT
|
||||
let sock_cleanup = sock.clone();
|
||||
if let Err(e) = ctrlc::set_handler(move || {
|
||||
let _ = std::fs::remove_file(&sock_cleanup);
|
||||
std::process::exit(0);
|
||||
}) {
|
||||
warn!("Failed to set signal handler: {}", e);
|
||||
}
|
||||
// SIGTERM/SIGINT are handled inside Server::run() via signal-hook,
|
||||
// which saves frecency before exiting.
|
||||
|
||||
if let Err(e) = server.run() {
|
||||
eprintln!("Server error: {e}");
|
||||
|
||||
@@ -10,6 +10,10 @@ use super::error::{PluginError, PluginResult};
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginManifest {
|
||||
pub plugin: PluginInfo,
|
||||
/// Provider declarations from [[providers]] sections (new-style)
|
||||
#[serde(default)]
|
||||
pub providers: Vec<ProviderSpec>,
|
||||
/// Legacy provides block (old-style)
|
||||
#[serde(default)]
|
||||
pub provides: PluginProvides,
|
||||
#[serde(default)]
|
||||
@@ -43,7 +47,7 @@ pub struct PluginInfo {
|
||||
#[serde(default = "default_owlry_version")]
|
||||
pub owlry_version: String,
|
||||
/// Entry point file (relative to plugin directory)
|
||||
#[serde(default = "default_entry")]
|
||||
#[serde(default = "default_entry", alias = "entry_point")]
|
||||
pub entry: String,
|
||||
}
|
||||
|
||||
@@ -52,7 +56,27 @@ fn default_owlry_version() -> String {
|
||||
}
|
||||
|
||||
fn default_entry() -> String {
|
||||
"init.lua".to_string()
|
||||
"main.lua".to_string()
|
||||
}
|
||||
|
||||
/// A provider declared in a [[providers]] section
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderSpec {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub prefix: Option<String>,
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
/// "static" (default) or "dynamic"
|
||||
#[serde(default = "default_provider_type", rename = "type")]
|
||||
pub provider_type: String,
|
||||
#[serde(default)]
|
||||
pub type_id: Option<String>,
|
||||
}
|
||||
|
||||
fn default_provider_type() -> String {
|
||||
"static".to_string()
|
||||
}
|
||||
|
||||
/// What the plugin provides
|
||||
@@ -278,7 +302,7 @@ version = "1.0.0"
|
||||
assert_eq!(manifest.plugin.id, "test-plugin");
|
||||
assert_eq!(manifest.plugin.name, "Test Plugin");
|
||||
assert_eq!(manifest.plugin.version, "1.0.0");
|
||||
assert_eq!(manifest.plugin.entry, "init.lua");
|
||||
assert_eq!(manifest.plugin.entry, "main.lua");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -317,6 +341,70 @@ api_url = "https://api.example.com"
|
||||
assert_eq!(manifest.permissions.run_commands, vec!["myapp"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_new_format_with_providers_array() {
|
||||
let toml_str = r#"
|
||||
[plugin]
|
||||
id = "my-plugin"
|
||||
name = "My Plugin"
|
||||
version = "0.1.0"
|
||||
description = "Test"
|
||||
entry_point = "main.rn"
|
||||
|
||||
[[providers]]
|
||||
id = "my-plugin"
|
||||
name = "My Plugin"
|
||||
type = "static"
|
||||
type_id = "myplugin"
|
||||
icon = "system-run"
|
||||
prefix = ":mp"
|
||||
"#;
|
||||
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(manifest.plugin.entry, "main.rn");
|
||||
assert_eq!(manifest.providers.len(), 1);
|
||||
let p = &manifest.providers[0];
|
||||
assert_eq!(p.id, "my-plugin");
|
||||
assert_eq!(p.name, "My Plugin");
|
||||
assert_eq!(p.provider_type, "static");
|
||||
assert_eq!(p.type_id.as_deref(), Some("myplugin"));
|
||||
assert_eq!(p.icon.as_deref(), Some("system-run"));
|
||||
assert_eq!(p.prefix.as_deref(), Some(":mp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_new_format_entry_point_alias() {
|
||||
let toml_str = r#"
|
||||
[plugin]
|
||||
id = "test"
|
||||
name = "Test"
|
||||
version = "1.0.0"
|
||||
entry_point = "main.lua"
|
||||
"#;
|
||||
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(manifest.plugin.entry, "main.lua");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_spec_defaults() {
|
||||
let toml_str = r#"
|
||||
[plugin]
|
||||
id = "test"
|
||||
name = "Test"
|
||||
version = "1.0.0"
|
||||
|
||||
[[providers]]
|
||||
id = "test"
|
||||
name = "Test"
|
||||
"#;
|
||||
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(manifest.providers.len(), 1);
|
||||
let p = &manifest.providers[0];
|
||||
assert_eq!(p.provider_type, "static"); // default
|
||||
assert!(p.prefix.is_none());
|
||||
assert!(p.icon.is_none());
|
||||
assert!(p.type_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_version_compatibility() {
|
||||
let toml_str = r#"
|
||||
|
||||
@@ -1,24 +1,38 @@
|
||||
//! Owlry Plugin System
|
||||
//!
|
||||
//! This module provides plugin support for extending owlry's functionality.
|
||||
//! Plugins can register providers, actions, themes, and hooks.
|
||||
//! This module loads and manages *plugins* — external code that extends owlry
|
||||
//! with additional *plugin providers* beyond the built-in ones.
|
||||
//!
|
||||
//! # Terminology
|
||||
//!
|
||||
//! | Term | Meaning |
|
||||
//! |------|---------|
|
||||
//! | **Provider** | Abstract source of [`LaunchItem`]s (the core interface) |
|
||||
//! | **Built-in provider** | Provider compiled into owlry-core (Application, Command) |
|
||||
//! | **Plugin** | External code (native `.so` or script) loaded at runtime |
|
||||
//! | **Plugin provider** | A provider registered by a plugin via its `type_id` |
|
||||
//! | **Native plugin** | Pre-compiled Rust `.so` from `/usr/lib/owlry/plugins/` |
|
||||
//! | **Script plugin** | Lua or Rune plugin from `~/.config/owlry/plugins/` |
|
||||
//!
|
||||
//! # Plugin Types
|
||||
//!
|
||||
//! - **Native plugins** (.so): Pre-compiled Rust plugins loaded from `/usr/lib/owlry/plugins/`
|
||||
//! - **Lua plugins**: Script-based plugins from `~/.config/owlry/plugins/` (requires `lua` feature)
|
||||
//! - **Native plugins** (`.so`): Pre-compiled Rust plugins loaded from `/usr/lib/owlry/plugins/`
|
||||
//! - **Script plugins**: Lua or Rune scripts from `~/.config/owlry/plugins/`
|
||||
//! (requires the corresponding runtime: `owlry-lua` or `owlry-rune`)
|
||||
//!
|
||||
//! # Plugin Structure (Lua)
|
||||
//! # Script Plugin Structure
|
||||
//!
|
||||
//! Each Lua plugin lives in its own directory under `~/.config/owlry/plugins/`:
|
||||
//! Each script plugin lives in its own directory under `~/.config/owlry/plugins/`:
|
||||
//!
|
||||
//! ```text
|
||||
//! ~/.config/owlry/plugins/
|
||||
//! my-plugin/
|
||||
//! plugin.toml # Plugin manifest
|
||||
//! init.lua # Entry point
|
||||
//! init.lua # Entry point (Lua) or init.rn (Rune)
|
||||
//! lib/ # Optional modules
|
||||
//! ```
|
||||
//!
|
||||
//! [`LaunchItem`]: crate::providers::LaunchItem
|
||||
|
||||
// Always available
|
||||
pub mod error;
|
||||
@@ -60,10 +74,9 @@ pub use manifest::{PluginManifest, check_compatibility, discover_plugins};
|
||||
#[cfg(feature = "lua")]
|
||||
mod lua_manager {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use manifest::{check_compatibility, discover_plugins};
|
||||
|
||||
@@ -73,8 +86,8 @@ mod lua_manager {
|
||||
plugins_dir: PathBuf,
|
||||
/// Current owlry version for compatibility checks
|
||||
owlry_version: String,
|
||||
/// Loaded plugins by ID (Rc<RefCell<>> allows sharing with LuaProviders)
|
||||
plugins: HashMap<String, Rc<RefCell<LoadedPlugin>>>,
|
||||
/// Loaded plugins by ID. Arc<Mutex<>> allows sharing with LuaProviders across threads.
|
||||
plugins: HashMap<String, Arc<Mutex<LoadedPlugin>>>,
|
||||
/// Plugin IDs that are explicitly disabled
|
||||
disabled: Vec<String>,
|
||||
}
|
||||
@@ -116,7 +129,7 @@ mod lua_manager {
|
||||
}
|
||||
|
||||
let plugin = LoadedPlugin::new(manifest, path);
|
||||
self.plugins.insert(id, Rc::new(RefCell::new(plugin)));
|
||||
self.plugins.insert(id, Arc::new(Mutex::new(plugin)));
|
||||
loaded_count += 1;
|
||||
}
|
||||
|
||||
@@ -129,7 +142,7 @@ mod lua_manager {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for (id, plugin_rc) in &self.plugins {
|
||||
let mut plugin = plugin_rc.borrow_mut();
|
||||
let mut plugin = plugin_rc.lock().unwrap();
|
||||
if !plugin.enabled {
|
||||
continue;
|
||||
}
|
||||
@@ -145,23 +158,23 @@ mod lua_manager {
|
||||
errors
|
||||
}
|
||||
|
||||
/// Get a loaded plugin by ID (returns Rc for shared ownership)
|
||||
/// Get a loaded plugin by ID
|
||||
#[allow(dead_code)]
|
||||
pub fn get(&self, id: &str) -> Option<Rc<RefCell<LoadedPlugin>>> {
|
||||
pub fn get(&self, id: &str) -> Option<Arc<Mutex<LoadedPlugin>>> {
|
||||
self.plugins.get(id).cloned()
|
||||
}
|
||||
|
||||
/// Get all loaded plugins
|
||||
#[allow(dead_code)]
|
||||
pub fn plugins(&self) -> impl Iterator<Item = Rc<RefCell<LoadedPlugin>>> + '_ {
|
||||
pub fn plugins(&self) -> impl Iterator<Item = Arc<Mutex<LoadedPlugin>>> + '_ {
|
||||
self.plugins.values().cloned()
|
||||
}
|
||||
|
||||
/// Get all enabled plugins
|
||||
pub fn enabled_plugins(&self) -> impl Iterator<Item = Rc<RefCell<LoadedPlugin>>> + '_ {
|
||||
pub fn enabled_plugins(&self) -> impl Iterator<Item = Arc<Mutex<LoadedPlugin>>> + '_ {
|
||||
self.plugins
|
||||
.values()
|
||||
.filter(|p| p.borrow().enabled)
|
||||
.filter(|p| p.lock().unwrap().enabled)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
@@ -174,7 +187,7 @@ mod lua_manager {
|
||||
/// Get the number of enabled plugins
|
||||
#[allow(dead_code)]
|
||||
pub fn enabled_count(&self) -> usize {
|
||||
self.plugins.values().filter(|p| p.borrow().enabled).count()
|
||||
self.plugins.values().filter(|p| p.lock().unwrap().enabled).count()
|
||||
}
|
||||
|
||||
/// Enable a plugin by ID
|
||||
@@ -184,7 +197,7 @@ mod lua_manager {
|
||||
.plugins
|
||||
.get(id)
|
||||
.ok_or_else(|| PluginError::NotFound(id.to_string()))?;
|
||||
let mut plugin = plugin_rc.borrow_mut();
|
||||
let mut plugin = plugin_rc.lock().unwrap();
|
||||
|
||||
if !plugin.enabled {
|
||||
plugin.enabled = true;
|
||||
@@ -202,7 +215,7 @@ mod lua_manager {
|
||||
.plugins
|
||||
.get(id)
|
||||
.ok_or_else(|| PluginError::NotFound(id.to_string()))?;
|
||||
plugin_rc.borrow_mut().enabled = false;
|
||||
plugin_rc.lock().unwrap().enabled = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -211,13 +224,14 @@ mod lua_manager {
|
||||
pub fn providers_for(&self, provider_name: &str) -> Vec<String> {
|
||||
self.enabled_plugins()
|
||||
.filter(|p| {
|
||||
p.borrow()
|
||||
p.lock()
|
||||
.unwrap()
|
||||
.manifest
|
||||
.provides
|
||||
.providers
|
||||
.contains(&provider_name.to_string())
|
||||
})
|
||||
.map(|p| p.borrow().id().to_string())
|
||||
.map(|p| p.lock().unwrap().id().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -225,21 +239,21 @@ mod lua_manager {
|
||||
#[allow(dead_code)]
|
||||
pub fn has_action_plugins(&self) -> bool {
|
||||
self.enabled_plugins()
|
||||
.any(|p| p.borrow().manifest.provides.actions)
|
||||
.any(|p| p.lock().unwrap().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)
|
||||
.any(|p| p.lock().unwrap().manifest.provides.hooks)
|
||||
}
|
||||
|
||||
/// Get all theme names provided by plugins
|
||||
#[allow(dead_code)]
|
||||
pub fn theme_names(&self) -> Vec<String> {
|
||||
self.enabled_plugins()
|
||||
.flat_map(|p| p.borrow().manifest.provides.themes.clone())
|
||||
.flat_map(|p| p.lock().unwrap().manifest.provides.themes.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -12,17 +12,70 @@
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsStr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Once};
|
||||
use std::sync::{Arc, Once, OnceLock, RwLock};
|
||||
|
||||
use libloading::Library;
|
||||
use log::{debug, error, info, warn};
|
||||
use owlry_plugin_api::{
|
||||
API_VERSION, HostAPI, NotifyUrgency, PluginInfo, PluginVTable, ProviderHandle, ProviderInfo,
|
||||
ProviderKind, RStr,
|
||||
ProviderKind, ROption, RStr, RString,
|
||||
};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::notify;
|
||||
|
||||
// ============================================================================
|
||||
// Plugin config access
|
||||
// ============================================================================
|
||||
|
||||
/// Shared config reference, set by the host before any plugins are loaded.
|
||||
static PLUGIN_CONFIG: OnceLock<Arc<RwLock<Config>>> = OnceLock::new();
|
||||
|
||||
/// Share the config with the native plugin loader so plugins can read their
|
||||
/// own config sections. Must be called before `NativePluginLoader::discover()`.
|
||||
pub fn set_shared_config(config: Arc<RwLock<Config>>) {
|
||||
let _ = PLUGIN_CONFIG.set(config);
|
||||
}
|
||||
|
||||
extern "C" fn host_get_config_string(plugin_id: RStr<'_>, key: RStr<'_>) -> ROption<RString> {
|
||||
let Some(cfg_arc) = PLUGIN_CONFIG.get() else {
|
||||
return ROption::RNone;
|
||||
};
|
||||
let Ok(cfg) = cfg_arc.read() else {
|
||||
return ROption::RNone;
|
||||
};
|
||||
match cfg.get_plugin_string(plugin_id.as_str(), key.as_str()) {
|
||||
Some(v) => ROption::RSome(RString::from(v)),
|
||||
None => ROption::RNone,
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn host_get_config_int(plugin_id: RStr<'_>, key: RStr<'_>) -> ROption<i64> {
|
||||
let Some(cfg_arc) = PLUGIN_CONFIG.get() else {
|
||||
return ROption::RNone;
|
||||
};
|
||||
let Ok(cfg) = cfg_arc.read() else {
|
||||
return ROption::RNone;
|
||||
};
|
||||
match cfg.get_plugin_int(plugin_id.as_str(), key.as_str()) {
|
||||
Some(v) => ROption::RSome(v),
|
||||
None => ROption::RNone,
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn host_get_config_bool(plugin_id: RStr<'_>, key: RStr<'_>) -> ROption<bool> {
|
||||
let Some(cfg_arc) = PLUGIN_CONFIG.get() else {
|
||||
return ROption::RNone;
|
||||
};
|
||||
let Ok(cfg) = cfg_arc.read() else {
|
||||
return ROption::RNone;
|
||||
};
|
||||
match cfg.get_plugin_bool(plugin_id.as_str(), key.as_str()) {
|
||||
Some(v) => ROption::RSome(v),
|
||||
None => ROption::RNone,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Host API Implementation
|
||||
// ============================================================================
|
||||
@@ -71,6 +124,9 @@ static HOST_API: HostAPI = HostAPI {
|
||||
log_info: host_log_info,
|
||||
log_warn: host_log_warn,
|
||||
log_error: host_log_error,
|
||||
get_config_string: host_get_config_string,
|
||||
get_config_int: host_get_config_int,
|
||||
get_config_bool: host_get_config_bool,
|
||||
};
|
||||
|
||||
/// Initialize the host API (called once before loading plugins)
|
||||
|
||||
@@ -10,14 +10,15 @@
|
||||
//! Note: This module is infrastructure for the runtime architecture. Full integration
|
||||
//! is pending Phase 5 (AUR Packaging) when runtime packages will be available.
|
||||
|
||||
use std::mem::ManuallyDrop;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use libloading::{Library, Symbol};
|
||||
use owlry_plugin_api::{PluginItem, RStr, RString, RVec};
|
||||
|
||||
use super::error::{PluginError, PluginResult};
|
||||
use crate::providers::{LaunchItem, Provider, ProviderType};
|
||||
use crate::providers::{ItemSource, LaunchItem, Provider, ProviderType};
|
||||
|
||||
/// System directory for runtime libraries
|
||||
pub const SYSTEM_RUNTIMES_DIR: &str = "/usr/lib/owlry/runtimes";
|
||||
@@ -50,6 +51,11 @@ pub type LuaProviderInfo = ScriptProviderInfo;
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct RuntimeHandle(pub *mut ());
|
||||
|
||||
// SAFETY: The underlying runtime state (Lua VM, Rune VM) is Send — mlua enables
|
||||
// the "send" feature and Rune wraps its state in Mutex internally. Access is always
|
||||
// serialized through Arc<Mutex<RuntimeHandle>>, so there are no data races.
|
||||
unsafe impl Send for RuntimeHandle {}
|
||||
|
||||
/// VTable for script runtime functions (used by both Lua and Rune)
|
||||
#[repr(C)]
|
||||
pub struct ScriptRuntimeVTable {
|
||||
@@ -69,12 +75,17 @@ pub struct ScriptRuntimeVTable {
|
||||
pub struct LoadedRuntime {
|
||||
/// Runtime name (for logging)
|
||||
name: &'static str,
|
||||
/// Keep library alive
|
||||
_library: Arc<Library>,
|
||||
/// Keep library alive — wrapped in ManuallyDrop so we never call dlclose().
|
||||
/// dlclose() unmaps the library code; any thread-local destructors inside the
|
||||
/// library then SIGSEGV when they try to run against the unmapped addresses.
|
||||
/// Runtime libraries live for the process lifetime, so leaking the handle is safe.
|
||||
_library: ManuallyDrop<Arc<Library>>,
|
||||
/// Runtime vtable
|
||||
vtable: &'static ScriptRuntimeVTable,
|
||||
/// Runtime handle (state)
|
||||
handle: RuntimeHandle,
|
||||
/// Runtime handle shared with all RuntimeProvider instances for this runtime.
|
||||
/// Mutex serializes concurrent vtable calls. Arc shares ownership so all
|
||||
/// RuntimeProviders can call into the runtime through the same handle.
|
||||
handle: Arc<Mutex<RuntimeHandle>>,
|
||||
/// Provider information
|
||||
providers: Vec<ScriptProviderInfo>,
|
||||
}
|
||||
@@ -124,10 +135,14 @@ impl LoadedRuntime {
|
||||
|
||||
// Initialize the runtime
|
||||
let plugins_dir_str = plugins_dir.to_string_lossy();
|
||||
let handle = (vtable.init)(RStr::from_str(&plugins_dir_str), RStr::from_str(owlry_version));
|
||||
let raw_handle = (vtable.init)(RStr::from_str(&plugins_dir_str), RStr::from_str(owlry_version));
|
||||
let handle = Arc::new(Mutex::new(raw_handle));
|
||||
|
||||
// Get provider information
|
||||
let providers_rvec = (vtable.providers)(handle);
|
||||
// Get provider information — lock to serialize the vtable call
|
||||
let providers_rvec = {
|
||||
let h = handle.lock().unwrap();
|
||||
(vtable.providers)(*h)
|
||||
};
|
||||
let providers: Vec<ScriptProviderInfo> = providers_rvec.into_iter().collect();
|
||||
|
||||
log::info!(
|
||||
@@ -138,7 +153,7 @@ impl LoadedRuntime {
|
||||
|
||||
Ok(Self {
|
||||
name,
|
||||
_library: library,
|
||||
_library: ManuallyDrop::new(library),
|
||||
vtable,
|
||||
handle,
|
||||
providers,
|
||||
@@ -155,8 +170,12 @@ 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,
|
||||
Arc::clone(&self.handle),
|
||||
info.clone(),
|
||||
);
|
||||
Box::new(provider) as Box<dyn Provider>
|
||||
})
|
||||
.collect()
|
||||
@@ -165,17 +184,16 @@ impl LoadedRuntime {
|
||||
|
||||
impl Drop for LoadedRuntime {
|
||||
fn drop(&mut self) {
|
||||
(self.vtable.drop)(self.handle);
|
||||
let h = self.handle.lock().unwrap();
|
||||
(self.vtable.drop)(*h);
|
||||
// Do NOT drop _library: ManuallyDrop ensures dlclose() is never called.
|
||||
// See field comment for rationale.
|
||||
}
|
||||
}
|
||||
|
||||
// LoadedRuntime needs to be Send + Sync because ProviderManager is shared across
|
||||
// threads via Arc<RwLock<ProviderManager>>.
|
||||
// Safety: RuntimeHandle is an opaque FFI handle accessed only through extern "C"
|
||||
// vtable functions. The same safety argument that applies to RuntimeProvider applies
|
||||
// here — all access is mediated by the vtable, and the runtime itself serializes access.
|
||||
unsafe impl Send for LoadedRuntime {}
|
||||
unsafe impl Sync for LoadedRuntime {}
|
||||
// LoadedRuntime is Send + Sync because:
|
||||
// - Arc<Mutex<RuntimeHandle>> is Send + Sync (RuntimeHandle: Send via unsafe impl above)
|
||||
// - All other fields are 'static references or Send types
|
||||
// No unsafe impl needed — this is derived automatically.
|
||||
|
||||
/// A provider backed by a dynamically loaded runtime
|
||||
pub struct RuntimeProvider {
|
||||
@@ -183,7 +201,9 @@ pub struct RuntimeProvider {
|
||||
#[allow(dead_code)]
|
||||
runtime_name: &'static str,
|
||||
vtable: &'static ScriptRuntimeVTable,
|
||||
handle: RuntimeHandle,
|
||||
/// Shared with the owning LoadedRuntime and sibling RuntimeProviders.
|
||||
/// Mutex serializes concurrent vtable calls on the same runtime handle.
|
||||
handle: Arc<Mutex<RuntimeHandle>>,
|
||||
info: ScriptProviderInfo,
|
||||
items: Vec<LaunchItem>,
|
||||
}
|
||||
@@ -192,7 +212,7 @@ impl RuntimeProvider {
|
||||
fn new(
|
||||
runtime_name: &'static str,
|
||||
vtable: &'static ScriptRuntimeVTable,
|
||||
handle: RuntimeHandle,
|
||||
handle: Arc<Mutex<RuntimeHandle>>,
|
||||
info: ScriptProviderInfo,
|
||||
) -> Self {
|
||||
Self {
|
||||
@@ -214,6 +234,7 @@ impl RuntimeProvider {
|
||||
command: item.command.to_string(),
|
||||
terminal: item.terminal,
|
||||
tags: item.keywords.iter().map(|s| s.to_string()).collect(),
|
||||
source: ItemSource::ScriptPlugin,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,7 +254,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);
|
||||
let items_rvec = {
|
||||
let h = self.handle.lock().unwrap();
|
||||
(self.vtable.refresh)(*h, name_rstr)
|
||||
};
|
||||
self.items = items_rvec
|
||||
.into_iter()
|
||||
.map(|i| self.convert_item(i))
|
||||
@@ -251,12 +275,10 @@ impl Provider for RuntimeProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// RuntimeProvider needs to be Send + Sync for the Provider trait.
|
||||
// Safety: RuntimeHandle is an opaque FFI handle accessed only through
|
||||
// extern "C" vtable functions. The same safety argument that justifies
|
||||
// Send applies to Sync — all access is mediated by the vtable.
|
||||
unsafe impl Send for RuntimeProvider {}
|
||||
unsafe impl Sync for RuntimeProvider {}
|
||||
// RuntimeProvider is Send + Sync because:
|
||||
// - Arc<Mutex<RuntimeHandle>> is Send + Sync (RuntimeHandle: Send via unsafe impl above)
|
||||
// - vtable is &'static (Send + Sync), info and items are Send
|
||||
// No unsafe impl needed — this is derived automatically.
|
||||
|
||||
/// Check if the Lua runtime is available
|
||||
pub fn lua_runtime_available() -> bool {
|
||||
|
||||
@@ -89,8 +89,13 @@ fn watch_loop(
|
||||
|
||||
if has_relevant_change {
|
||||
info!("Plugin file change detected, reloading runtimes...");
|
||||
let mut pm_guard = pm.write().unwrap_or_else(|e| e.into_inner());
|
||||
pm_guard.reload_runtimes();
|
||||
match pm.write() {
|
||||
Ok(mut pm_guard) => pm_guard.reload_runtimes(),
|
||||
Err(_) => {
|
||||
log::error!("Plugin watcher: provider lock poisoned; stopping watcher");
|
||||
return Err(Box::from("provider lock poisoned"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use super::{LaunchItem, Provider, ProviderType};
|
||||
use std::collections::HashSet;
|
||||
|
||||
use super::{ItemSource, LaunchItem, Provider, ProviderType};
|
||||
use crate::paths;
|
||||
use freedesktop_desktop_entry::{DesktopEntry, Iter};
|
||||
use log::{debug, warn};
|
||||
@@ -58,9 +60,19 @@ fn clean_desktop_exec_field(cmd: &str) -> String {
|
||||
}
|
||||
|
||||
// Clean up any double spaces that may have resulted from removing field codes
|
||||
let mut cleaned = result.trim().to_string();
|
||||
while cleaned.contains(" ") {
|
||||
cleaned = cleaned.replace(" ", " ");
|
||||
let trimmed = result.trim();
|
||||
let mut cleaned = String::with_capacity(trimmed.len());
|
||||
let mut prev_space = false;
|
||||
for c in trimmed.chars() {
|
||||
if c == ' ' {
|
||||
if !prev_space {
|
||||
cleaned.push(' ');
|
||||
}
|
||||
prev_space = true;
|
||||
} else {
|
||||
cleaned.push(c);
|
||||
prev_space = false;
|
||||
}
|
||||
}
|
||||
|
||||
cleaned
|
||||
@@ -108,7 +120,21 @@ impl Provider for ApplicationProvider {
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
// Track seen .desktop file basenames to skip duplicates.
|
||||
// XDG dirs are iterated user-first per spec, so the first occurrence wins.
|
||||
let mut seen_basenames: HashSet<String> = HashSet::new();
|
||||
|
||||
for path in Iter::new(dirs.into_iter()) {
|
||||
// Skip if we've already loaded a .desktop with this basename from a higher-priority dir.
|
||||
if path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|basename| !seen_basenames.insert(basename.to_string()))
|
||||
{
|
||||
debug!("Skipping duplicate desktop entry: {:?}", path);
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = match std::fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
@@ -186,6 +212,7 @@ impl Provider for ApplicationProvider {
|
||||
command: run_cmd,
|
||||
terminal: desktop_entry.terminal(),
|
||||
tags,
|
||||
source: ItemSource::Core,
|
||||
};
|
||||
|
||||
self.items.push(item);
|
||||
@@ -271,4 +298,11 @@ mod tests {
|
||||
"env FOO=bar BAZ=qux myapp"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_desktop_exec_collapses_spaces() {
|
||||
assert_eq!(clean_desktop_exec_field("app --flag arg"), "app --flag arg");
|
||||
let input = format!("app{}arg", " ".repeat(100));
|
||||
assert_eq!(clean_desktop_exec_field(&input), "app arg");
|
||||
}
|
||||
}
|
||||
|
||||
238
crates/owlry-core/src/providers/calculator.rs
Normal file
238
crates/owlry-core/src/providers/calculator.rs
Normal file
@@ -0,0 +1,238 @@
|
||||
use super::{DynamicProvider, ItemSource, LaunchItem, ProviderType};
|
||||
|
||||
/// Built-in calculator provider. Evaluates mathematical expressions via `meval`.
|
||||
///
|
||||
/// Triggered by:
|
||||
/// - `= expr` / `=expr` / `calc expr` (explicit prefix)
|
||||
/// - Raw math expressions containing operators or known functions (auto-detect)
|
||||
pub(crate) struct CalculatorProvider;
|
||||
|
||||
impl DynamicProvider for CalculatorProvider {
|
||||
fn name(&self) -> &str {
|
||||
"Calculator"
|
||||
}
|
||||
|
||||
fn provider_type(&self) -> ProviderType {
|
||||
ProviderType::Plugin("calc".into())
|
||||
}
|
||||
|
||||
fn priority(&self) -> u32 {
|
||||
10_000
|
||||
}
|
||||
|
||||
fn query(&self, query: &str) -> Vec<LaunchItem> {
|
||||
let expr = match extract_expression(query) {
|
||||
Some(e) if !e.is_empty() => e,
|
||||
_ => return Vec::new(),
|
||||
};
|
||||
|
||||
match meval::eval_str(expr) {
|
||||
Ok(result) => {
|
||||
let display = format_result(result);
|
||||
let copy_cmd = format!(
|
||||
"printf '%s' '{}' | wl-copy",
|
||||
display.replace('\'', "'\\''")
|
||||
);
|
||||
vec![LaunchItem {
|
||||
id: format!("calc:{}", expr),
|
||||
name: display.clone(),
|
||||
description: Some(format!("= {}", expr)),
|
||||
icon: Some("accessories-calculator".into()),
|
||||
provider: ProviderType::Plugin("calc".into()),
|
||||
command: copy_cmd,
|
||||
terminal: false,
|
||||
tags: vec!["math".into(), "calculator".into()],
|
||||
source: ItemSource::Core,
|
||||
}]
|
||||
}
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the math expression from a query string.
|
||||
///
|
||||
/// Handles:
|
||||
/// - `= expr` and `=expr` (explicit calculator prefix)
|
||||
/// - `calc expr` (word prefix)
|
||||
/// - Raw expressions if they look like math (auto-detect)
|
||||
///
|
||||
/// Returns `None` only when input is empty after trimming.
|
||||
fn extract_expression(query: &str) -> Option<&str> {
|
||||
let trimmed = query.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Explicit prefixes
|
||||
if let Some(rest) = trimmed.strip_prefix("= ") {
|
||||
return Some(rest.trim());
|
||||
}
|
||||
if let Some(rest) = trimmed.strip_prefix('=') {
|
||||
return Some(rest.trim());
|
||||
}
|
||||
if let Some(rest) = trimmed.strip_prefix("calc ") {
|
||||
return Some(rest.trim());
|
||||
}
|
||||
|
||||
// Auto-detect: only forward if the expression looks like math.
|
||||
// Plain words like "firefox" should not reach meval.
|
||||
if looks_like_math(trimmed) {
|
||||
Some(trimmed)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Heuristic: does this string look like a math expression?
|
||||
///
|
||||
/// Returns true when the string contains binary operators, digits mixed with
|
||||
/// operators, or known function names. Plain alphabetic words return false.
|
||||
fn looks_like_math(s: &str) -> bool {
|
||||
// Must contain at least one digit or a known constant/function name
|
||||
let has_digit = s.chars().any(|c| c.is_ascii_digit());
|
||||
let has_operator = s.contains('+')
|
||||
|| s.contains('*')
|
||||
|| s.contains('/')
|
||||
|| s.contains('^')
|
||||
|| s.contains('%');
|
||||
// Subtraction/negation is ambiguous; only count it as an operator when
|
||||
// there are already digits present to avoid matching bare words with hyphens.
|
||||
let has_minus_operator = has_digit && s.contains('-');
|
||||
|
||||
// Known math functions that are safe to auto-evaluate
|
||||
const MATH_FUNCTIONS: &[&str] = &[
|
||||
"sqrt", "sin", "cos", "tan", "log", "ln", "abs", "floor", "ceil", "round",
|
||||
];
|
||||
let has_function = MATH_FUNCTIONS.iter().any(|f| s.contains(f));
|
||||
|
||||
has_digit && (has_operator || has_minus_operator) || has_function
|
||||
}
|
||||
|
||||
/// Format a floating-point result for display.
|
||||
///
|
||||
/// Integer-valued results are shown as integers with thousands separators.
|
||||
/// Non-integer results are shown with up to 10 decimal places, trailing zeros trimmed.
|
||||
fn format_result(result: f64) -> String {
|
||||
if result.fract() == 0.0 && result.abs() < 1e15 {
|
||||
format_integer_with_separators(result as i64)
|
||||
} else {
|
||||
let formatted = format!("{:.10}", result);
|
||||
formatted
|
||||
.trim_end_matches('0')
|
||||
.trim_end_matches('.')
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn format_integer_with_separators(n: i64) -> String {
|
||||
let s = n.unsigned_abs().to_string();
|
||||
let with_commas = s
|
||||
.as_bytes()
|
||||
.rchunks(3)
|
||||
.rev()
|
||||
.map(|chunk| std::str::from_utf8(chunk).unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
|
||||
if n < 0 {
|
||||
format!("-{}", with_commas)
|
||||
} else {
|
||||
with_commas
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn query(q: &str) -> Vec<LaunchItem> {
|
||||
CalculatorProvider.query(q)
|
||||
}
|
||||
|
||||
// --- Trigger prefix tests ---
|
||||
|
||||
#[test]
|
||||
fn equals_prefix_addition() {
|
||||
let results = query("= 5+3");
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].name, "8");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calc_prefix_multiplication() {
|
||||
let results = query("calc 10*2");
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].name, "20");
|
||||
}
|
||||
|
||||
// --- Auto-detect tests ---
|
||||
|
||||
#[test]
|
||||
fn auto_detect_addition() {
|
||||
let results = query("5+3");
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].name, "8");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equals_prefix_complex_expression() {
|
||||
let results = query("= sqrt(16) + 2^3");
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].name, "12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_result() {
|
||||
let results = query("= 10/3");
|
||||
assert_eq!(results.len(), 1);
|
||||
assert!(
|
||||
results[0].name.starts_with("3.333"),
|
||||
"expected result starting with 3.333, got: {}",
|
||||
results[0].name
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_integer_thousands_separators() {
|
||||
let results = query("= 1000000");
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].name, "1,000,000");
|
||||
}
|
||||
|
||||
// --- Invalid / non-math input ---
|
||||
|
||||
#[test]
|
||||
fn invalid_expression_returns_empty() {
|
||||
let results = query("= 5 +");
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_text_returns_empty() {
|
||||
let results = query("firefox");
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
// --- Metadata tests ---
|
||||
|
||||
#[test]
|
||||
fn provider_type_is_calc_plugin() {
|
||||
assert_eq!(
|
||||
CalculatorProvider.provider_type(),
|
||||
ProviderType::Plugin("calc".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn description_shows_expression() {
|
||||
let results = query("= 5+3");
|
||||
assert_eq!(results[0].description.as_deref(), Some("= 5+3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_command_contains_wl_copy() {
|
||||
let results = query("= 5+3");
|
||||
assert!(results[0].command.contains("wl-copy"));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{LaunchItem, Provider, ProviderType};
|
||||
use super::{ItemSource, LaunchItem, Provider, ProviderType};
|
||||
use log::debug;
|
||||
use std::collections::HashSet;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
@@ -89,6 +89,7 @@ impl Provider for CommandProvider {
|
||||
command: name,
|
||||
terminal: false,
|
||||
tags: Vec::new(),
|
||||
source: ItemSource::Core,
|
||||
};
|
||||
|
||||
self.items.push(item);
|
||||
|
||||
1126
crates/owlry-core/src/providers/config_editor.rs
Normal file
1126
crates/owlry-core/src/providers/config_editor.rs
Normal file
File diff suppressed because it is too large
Load Diff
313
crates/owlry-core/src/providers/converter/currency.rs
Normal file
313
crates/owlry-core/src/providers/converter/currency.rs
Normal file
@@ -0,0 +1,313 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const ECB_URL: &str = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml";
|
||||
const CACHE_MAX_AGE_SECS: u64 = 86400; // 24 hours
|
||||
|
||||
static CACHED_RATES: Mutex<Option<CurrencyRates>> = Mutex::new(None);
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CurrencyRates {
|
||||
pub date: String,
|
||||
pub rates: HashMap<String, f64>,
|
||||
}
|
||||
|
||||
struct CurrencyAlias {
|
||||
code: &'static str,
|
||||
aliases: &'static [&'static str],
|
||||
}
|
||||
|
||||
static CURRENCY_ALIASES: &[CurrencyAlias] = &[
|
||||
CurrencyAlias {
|
||||
code: "EUR",
|
||||
aliases: &["eur", "euro", "euros", "€"],
|
||||
},
|
||||
CurrencyAlias {
|
||||
code: "USD",
|
||||
aliases: &["usd", "dollar", "dollars", "$", "us_dollar"],
|
||||
},
|
||||
CurrencyAlias {
|
||||
code: "GBP",
|
||||
aliases: &["gbp", "pound_sterling", "£", "british_pound", "pounds"],
|
||||
},
|
||||
CurrencyAlias {
|
||||
code: "JPY",
|
||||
aliases: &["jpy", "yen", "¥", "japanese_yen"],
|
||||
},
|
||||
CurrencyAlias {
|
||||
code: "CHF",
|
||||
aliases: &["chf", "swiss_franc", "francs"],
|
||||
},
|
||||
CurrencyAlias {
|
||||
code: "CAD",
|
||||
aliases: &["cad", "canadian_dollar", "c$"],
|
||||
},
|
||||
CurrencyAlias {
|
||||
code: "AUD",
|
||||
aliases: &["aud", "australian_dollar", "a$"],
|
||||
},
|
||||
CurrencyAlias {
|
||||
code: "CNY",
|
||||
aliases: &["cny", "yuan", "renminbi", "rmb"],
|
||||
},
|
||||
CurrencyAlias {
|
||||
code: "SEK",
|
||||
aliases: &["sek", "swedish_krona", "kronor"],
|
||||
},
|
||||
CurrencyAlias {
|
||||
code: "NOK",
|
||||
aliases: &["nok", "norwegian_krone"],
|
||||
},
|
||||
CurrencyAlias {
|
||||
code: "DKK",
|
||||
aliases: &["dkk", "danish_krone"],
|
||||
},
|
||||
CurrencyAlias {
|
||||
code: "PLN",
|
||||
aliases: &["pln", "zloty", "złoty"],
|
||||
},
|
||||
CurrencyAlias {
|
||||
code: "CZK",
|
||||
aliases: &["czk", "czech_koruna"],
|
||||
},
|
||||
CurrencyAlias {
|
||||
code: "HUF",
|
||||
aliases: &["huf", "forint"],
|
||||
},
|
||||
CurrencyAlias {
|
||||
code: "TRY",
|
||||
aliases: &["try", "turkish_lira", "lira"],
|
||||
},
|
||||
];
|
||||
|
||||
pub fn resolve_currency_code(alias: &str) -> Option<&'static str> {
|
||||
let lower = alias.to_lowercase();
|
||||
|
||||
// Check aliases
|
||||
for ca in CURRENCY_ALIASES {
|
||||
if ca.aliases.contains(&lower.as_str()) {
|
||||
return Some(ca.code); // ca.code is already &'static str
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a raw 3-letter ISO code we know about
|
||||
let upper = alias.to_uppercase();
|
||||
if upper.len() == 3 {
|
||||
if upper == "EUR" {
|
||||
return Some("EUR");
|
||||
}
|
||||
if let Some(rates) = get_rates()
|
||||
&& rates.rates.contains_key(&upper)
|
||||
{
|
||||
for ca in CURRENCY_ALIASES {
|
||||
if ca.code == upper {
|
||||
return Some(ca.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn is_currency_alias(alias: &str) -> bool {
|
||||
resolve_currency_code(alias).is_some()
|
||||
}
|
||||
|
||||
pub fn get_rates() -> Option<CurrencyRates> {
|
||||
// Check memory cache first
|
||||
{
|
||||
let cache = CACHED_RATES.lock().ok()?;
|
||||
if let Some(ref rates) = *cache {
|
||||
return Some(rates.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Try disk cache
|
||||
if let Some(rates) = load_cache()
|
||||
&& !is_stale(&rates)
|
||||
{
|
||||
let mut cache = CACHED_RATES.lock().ok()?;
|
||||
*cache = Some(rates.clone());
|
||||
return Some(rates);
|
||||
}
|
||||
|
||||
// Fetch fresh rates
|
||||
if let Some(rates) = fetch_rates() {
|
||||
save_cache(&rates);
|
||||
let mut cache = CACHED_RATES.lock().ok()?;
|
||||
*cache = Some(rates.clone());
|
||||
return Some(rates);
|
||||
}
|
||||
|
||||
// Fall back to stale cache
|
||||
load_cache()
|
||||
}
|
||||
|
||||
fn cache_path() -> Option<PathBuf> {
|
||||
let cache_dir = dirs::cache_dir()?.join("owlry");
|
||||
Some(cache_dir.join("ecb_rates.json"))
|
||||
}
|
||||
|
||||
fn load_cache() -> Option<CurrencyRates> {
|
||||
let path = cache_path()?;
|
||||
let content = fs::read_to_string(path).ok()?;
|
||||
serde_json::from_str(&content).ok()
|
||||
}
|
||||
|
||||
fn save_cache(rates: &CurrencyRates) {
|
||||
if let Some(path) = cache_path() {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).ok();
|
||||
}
|
||||
if let Ok(json) = serde_json::to_string_pretty(rates) {
|
||||
fs::write(path, json).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_stale(_rates: &CurrencyRates) -> bool {
|
||||
let path = match cache_path() {
|
||||
Some(p) => p,
|
||||
None => return true,
|
||||
};
|
||||
let metadata = match fs::metadata(path) {
|
||||
Ok(m) => m,
|
||||
Err(_) => return true,
|
||||
};
|
||||
let modified = match metadata.modified() {
|
||||
Ok(t) => t,
|
||||
Err(_) => return true,
|
||||
};
|
||||
match SystemTime::now().duration_since(modified) {
|
||||
Ok(age) => age.as_secs() > CACHE_MAX_AGE_SECS,
|
||||
Err(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_rates() -> Option<CurrencyRates> {
|
||||
let response = reqwest::blocking::get(ECB_URL).ok()?;
|
||||
let body = response.text().ok()?;
|
||||
parse_ecb_xml(&body)
|
||||
}
|
||||
|
||||
fn parse_ecb_xml(xml: &str) -> Option<CurrencyRates> {
|
||||
let mut rates = HashMap::new();
|
||||
let mut date = String::new();
|
||||
|
||||
for line in xml.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Extract date: <Cube time='2026-03-26'>
|
||||
if trimmed.contains("time=")
|
||||
&& let Some(start) = trimmed.find("time='")
|
||||
{
|
||||
let rest = &trimmed[start + 6..];
|
||||
if let Some(end) = rest.find('\'') {
|
||||
date = rest[..end].to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Extract rate: <Cube currency='USD' rate='1.0832'/>
|
||||
if trimmed.contains("currency=") && trimmed.contains("rate=") {
|
||||
let currency = extract_attr(trimmed, "currency")?;
|
||||
let rate_str = extract_attr(trimmed, "rate")?;
|
||||
let rate: f64 = rate_str.parse().ok()?;
|
||||
rates.insert(currency, rate);
|
||||
}
|
||||
}
|
||||
|
||||
if rates.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(CurrencyRates { date, rates })
|
||||
}
|
||||
|
||||
fn extract_attr(line: &str, attr: &str) -> Option<String> {
|
||||
let needle = format!("{}='", attr);
|
||||
let start = line.find(&needle)? + needle.len();
|
||||
let rest = &line[start..];
|
||||
let end = rest.find('\'')?;
|
||||
Some(rest[..end].to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_resolve_currency_code_iso() {
|
||||
assert_eq!(resolve_currency_code("usd"), Some("USD"));
|
||||
assert_eq!(resolve_currency_code("EUR"), Some("EUR"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_currency_code_name() {
|
||||
assert_eq!(resolve_currency_code("dollar"), Some("USD"));
|
||||
assert_eq!(resolve_currency_code("euro"), Some("EUR"));
|
||||
assert_eq!(resolve_currency_code("pounds"), Some("GBP"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_currency_code_symbol() {
|
||||
assert_eq!(resolve_currency_code("$"), Some("USD"));
|
||||
assert_eq!(resolve_currency_code("€"), Some("EUR"));
|
||||
assert_eq!(resolve_currency_code("£"), Some("GBP"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_currency_unknown() {
|
||||
assert_eq!(resolve_currency_code("xyz"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_currency_alias() {
|
||||
assert!(is_currency_alias("usd"));
|
||||
assert!(is_currency_alias("euro"));
|
||||
assert!(is_currency_alias("$"));
|
||||
assert!(!is_currency_alias("km"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_ecb_xml() {
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gesmes:Envelope xmlns:gesmes="http://www.gesmes.org/xml/2002-08-01" xmlns="http://www.ecb.int/vocabulary/2002-08-01/eurofxref">
|
||||
<gesmes:subject>Reference rates</gesmes:subject>
|
||||
<Cube>
|
||||
<Cube time='2026-03-26'>
|
||||
<Cube currency='USD' rate='1.0832'/>
|
||||
<Cube currency='JPY' rate='161.94'/>
|
||||
<Cube currency='GBP' rate='0.83450'/>
|
||||
</Cube>
|
||||
</Cube>
|
||||
</gesmes:Envelope>"#;
|
||||
|
||||
let rates = parse_ecb_xml(xml).unwrap();
|
||||
assert!((rates.rates["USD"] - 1.0832).abs() < 0.001);
|
||||
assert!((rates.rates["GBP"] - 0.8345).abs() < 0.001);
|
||||
assert!((rates.rates["JPY"] - 161.94).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_roundtrip() {
|
||||
let rates = CurrencyRates {
|
||||
date: "2026-03-26".to_string(),
|
||||
rates: {
|
||||
let mut m = HashMap::new();
|
||||
m.insert("USD".to_string(), 1.0832);
|
||||
m.insert("GBP".to_string(), 0.8345);
|
||||
m
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_string(&rates).unwrap();
|
||||
let parsed: CurrencyRates = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.rates["USD"], 1.0832);
|
||||
}
|
||||
}
|
||||
184
crates/owlry-core/src/providers/converter/mod.rs
Normal file
184
crates/owlry-core/src/providers/converter/mod.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
mod currency;
|
||||
mod parser;
|
||||
mod units;
|
||||
|
||||
use super::{DynamicProvider, ItemSource, LaunchItem, ProviderType};
|
||||
|
||||
const PROVIDER_TYPE_ID: &str = "conv";
|
||||
const PROVIDER_ICON: &str = "edit-find-replace-symbolic";
|
||||
|
||||
pub struct ConverterProvider;
|
||||
|
||||
impl ConverterProvider {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl DynamicProvider for ConverterProvider {
|
||||
fn name(&self) -> &str {
|
||||
"Converter"
|
||||
}
|
||||
|
||||
fn provider_type(&self) -> ProviderType {
|
||||
ProviderType::Plugin(PROVIDER_TYPE_ID.into())
|
||||
}
|
||||
|
||||
fn priority(&self) -> u32 {
|
||||
9_000
|
||||
}
|
||||
|
||||
fn query(&self, query: &str) -> Vec<LaunchItem> {
|
||||
let query_str = query.trim();
|
||||
// Strip prefix
|
||||
let input = if let Some(rest) = query_str.strip_prefix('>') {
|
||||
rest.trim()
|
||||
} else {
|
||||
query_str
|
||||
};
|
||||
|
||||
let parsed = match parser::parse_conversion(input) {
|
||||
Some(p) => p,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
|
||||
let results = if let Some(ref target) = parsed.target_unit {
|
||||
units::convert_to(&parsed.value, &parsed.from_unit, target)
|
||||
.into_iter()
|
||||
.collect()
|
||||
} else {
|
||||
units::convert_common(&parsed.value, &parsed.from_unit)
|
||||
};
|
||||
|
||||
results
|
||||
.into_iter()
|
||||
.map(|r| LaunchItem {
|
||||
id: format!("conv:{}:{}:{}", parsed.from_unit, r.target_symbol, r.value),
|
||||
name: r.display_value.clone(),
|
||||
description: Some(format!(
|
||||
"{} {} = {}",
|
||||
format_number(parsed.value),
|
||||
parsed.from_symbol,
|
||||
r.display_value,
|
||||
)),
|
||||
icon: Some(PROVIDER_ICON.into()),
|
||||
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
|
||||
command: format!(
|
||||
"printf '%s' '{}' | wl-copy",
|
||||
r.raw_value.replace('\'', "'\\''")
|
||||
),
|
||||
terminal: false,
|
||||
tags: vec!["converter".into(), "units".into()],
|
||||
source: ItemSource::Core,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn format_number(n: f64) -> String {
|
||||
if n.fract() == 0.0 && n.abs() < 1e15 {
|
||||
let i = n as i64;
|
||||
if i.abs() >= 1000 {
|
||||
format_with_separators(i)
|
||||
} else {
|
||||
format!("{}", i)
|
||||
}
|
||||
} else {
|
||||
format!("{:.4}", n)
|
||||
.trim_end_matches('0')
|
||||
.trim_end_matches('.')
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_with_separators(n: i64) -> String {
|
||||
let s = n.abs().to_string();
|
||||
let mut result = String::new();
|
||||
for (i, c) in s.chars().rev().enumerate() {
|
||||
if i > 0 && i % 3 == 0 {
|
||||
result.push(',');
|
||||
}
|
||||
result.push(c);
|
||||
}
|
||||
if n < 0 {
|
||||
result.push('-');
|
||||
}
|
||||
result.chars().rev().collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn query(input: &str) -> Vec<LaunchItem> {
|
||||
ConverterProvider::new().query(input)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prefix_trigger() {
|
||||
let r = query("> 100 km to mi");
|
||||
assert!(!r.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auto_detect() {
|
||||
let r = query("100 km to mi");
|
||||
assert!(!r.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_common_conversions() {
|
||||
let r = query("> 100 km");
|
||||
assert!(r.len() > 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_temperature() {
|
||||
let r = query("102F to C");
|
||||
assert!(!r.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nonsense_returns_empty() {
|
||||
assert!(query("hello world").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_type() {
|
||||
assert_eq!(
|
||||
ConverterProvider::new().provider_type(),
|
||||
ProviderType::Plugin("conv".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_double_unit() {
|
||||
let r = query("100 km to mi");
|
||||
if let Some(item) = r.first() {
|
||||
let desc = item.description.as_deref().unwrap();
|
||||
assert!(!desc.ends_with(" mi mi"), "double unit in: {}", desc);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_number_integer() {
|
||||
assert_eq!(format_number(42.0), "42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_number_large_integer() {
|
||||
assert_eq!(format_number(1000000.0), "1,000,000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_number_decimal() {
|
||||
assert_eq!(format_number(3.14), "3.14");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_with_separators() {
|
||||
assert_eq!(format_with_separators(1234567), "1,234,567");
|
||||
assert_eq!(format_with_separators(999), "999");
|
||||
assert_eq!(format_with_separators(-1234), "-1,234");
|
||||
}
|
||||
}
|
||||
235
crates/owlry-core/src/providers/converter/parser.rs
Normal file
235
crates/owlry-core/src/providers/converter/parser.rs
Normal file
@@ -0,0 +1,235 @@
|
||||
use super::units;
|
||||
|
||||
pub struct ParsedQuery {
|
||||
pub value: f64,
|
||||
pub from_unit: String,
|
||||
pub from_symbol: String,
|
||||
pub target_unit: Option<String>,
|
||||
}
|
||||
|
||||
pub fn parse_conversion(input: &str) -> Option<ParsedQuery> {
|
||||
let input = input.trim();
|
||||
if input.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Extract leading number
|
||||
let (value, rest) = extract_number(input)?;
|
||||
let rest = rest.trim();
|
||||
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Split on " to " or " in " (case-insensitive)
|
||||
let (from_str, target_str) = split_on_connector(rest);
|
||||
|
||||
// Resolve from unit
|
||||
let from_lower = from_str.trim().to_lowercase();
|
||||
let from_symbol = units::find_unit(&from_lower)?;
|
||||
|
||||
let from_symbol_str = from_symbol.to_string();
|
||||
|
||||
// Resolve target unit if present
|
||||
let target_unit = target_str.and_then(|t| {
|
||||
let t_lower = t.trim().to_lowercase();
|
||||
if t_lower.is_empty() {
|
||||
None
|
||||
} else {
|
||||
units::find_unit(&t_lower).map(|_| t_lower)
|
||||
}
|
||||
});
|
||||
|
||||
Some(ParsedQuery {
|
||||
value,
|
||||
from_unit: from_lower,
|
||||
from_symbol: from_symbol_str,
|
||||
target_unit,
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_number(input: &str) -> Option<(f64, &str)> {
|
||||
let bytes = input.as_bytes();
|
||||
let mut i = 0;
|
||||
|
||||
// Optional negative sign
|
||||
if i < bytes.len() && bytes[i] == b'-' {
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// Must have at least one digit or start with .
|
||||
if i >= bytes.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let start_digits = i;
|
||||
|
||||
// Integer part
|
||||
while i < bytes.len() && bytes[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// Decimal part
|
||||
if i < bytes.len() && bytes[i] == b'.' {
|
||||
i += 1;
|
||||
while i < bytes.len() && bytes[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if i == start_digits && !(i > 0 && bytes[0] == b'-') {
|
||||
// No digits found (and not just a negative sign before a dot)
|
||||
// Handle ".5" case
|
||||
if bytes[start_digits] == b'.' {
|
||||
// already advanced past dot above
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
if i == 0 || (i == 1 && bytes[0] == b'-') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let num_str = &input[..i];
|
||||
let value: f64 = num_str.parse().ok()?;
|
||||
let rest = &input[i..];
|
||||
|
||||
Some((value, rest))
|
||||
}
|
||||
|
||||
fn split_on_connector(input: &str) -> (&str, Option<&str>) {
|
||||
let lower = input.to_lowercase();
|
||||
|
||||
// Try " to " first
|
||||
if let Some(pos) = lower.find(" to ") {
|
||||
let from = &input[..pos];
|
||||
let target = &input[pos + 4..];
|
||||
return (from, Some(target));
|
||||
}
|
||||
|
||||
// Try " in "
|
||||
if let Some(pos) = lower.find(" in ") {
|
||||
let from = &input[..pos];
|
||||
let target = &input[pos + 4..];
|
||||
return (from, Some(target));
|
||||
}
|
||||
|
||||
(input, None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_number_and_unit_with_space() {
|
||||
let p = parse_conversion("100 km").unwrap();
|
||||
assert!((p.value - 100.0).abs() < 0.001);
|
||||
assert_eq!(p.from_unit, "km");
|
||||
assert!(p.target_unit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_number_and_unit_no_space() {
|
||||
let p = parse_conversion("100km").unwrap();
|
||||
assert!((p.value - 100.0).abs() < 0.001);
|
||||
assert_eq!(p.from_unit, "km");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_target_to() {
|
||||
let p = parse_conversion("100 km to mi").unwrap();
|
||||
assert!((p.value - 100.0).abs() < 0.001);
|
||||
assert_eq!(p.from_unit, "km");
|
||||
assert_eq!(p.target_unit.as_deref(), Some("mi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_target_in() {
|
||||
let p = parse_conversion("100 km in mi").unwrap();
|
||||
assert_eq!(p.target_unit.as_deref(), Some("mi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_temperature_no_space() {
|
||||
let p = parse_conversion("102F to C").unwrap();
|
||||
assert!((p.value - 102.0).abs() < 0.001);
|
||||
assert_eq!(p.from_unit, "f");
|
||||
assert_eq!(p.target_unit.as_deref(), Some("c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_temperature_with_space() {
|
||||
let p = parse_conversion("102 F in K").unwrap();
|
||||
assert!((p.value - 102.0).abs() < 0.001);
|
||||
assert_eq!(p.from_unit, "f");
|
||||
assert_eq!(p.target_unit.as_deref(), Some("k"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decimal_number() {
|
||||
let p = parse_conversion("3.5 kg to lb").unwrap();
|
||||
assert!((p.value - 3.5).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decimal_starting_with_dot() {
|
||||
let p = parse_conversion(".5 kg").unwrap();
|
||||
assert!((p.value - 0.5).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_unit_names() {
|
||||
let p = parse_conversion("100 kilometers to miles").unwrap();
|
||||
assert_eq!(p.from_unit, "kilometers");
|
||||
assert_eq!(p.target_unit.as_deref(), Some("miles"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_case_insensitive() {
|
||||
let p = parse_conversion("100 KM TO MI").unwrap();
|
||||
assert_eq!(p.from_unit, "km");
|
||||
assert_eq!(p.target_unit.as_deref(), Some("mi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_currency() {
|
||||
let p = parse_conversion("100 eur to usd").unwrap();
|
||||
assert_eq!(p.from_unit, "eur");
|
||||
assert_eq!(p.target_unit.as_deref(), Some("usd"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_number_returns_none() {
|
||||
assert!(parse_conversion("km to mi").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_unit_returns_none() {
|
||||
assert!(parse_conversion("100 xyz to abc").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_returns_none() {
|
||||
assert!(parse_conversion("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_number_only_returns_none() {
|
||||
assert!(parse_conversion("100").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compound_unit_alias() {
|
||||
let p = parse_conversion("100 km/h to mph").unwrap();
|
||||
assert_eq!(p.from_unit, "km/h");
|
||||
assert_eq!(p.target_unit.as_deref(), Some("mph"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multi_word_unit() {
|
||||
let p = parse_conversion("100 fl_oz to ml").unwrap();
|
||||
assert_eq!(p.from_unit, "fl_oz");
|
||||
}
|
||||
}
|
||||
944
crates/owlry-core/src/providers/converter/units.rs
Normal file
944
crates/owlry-core/src/providers/converter/units.rs
Normal file
@@ -0,0 +1,944 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use super::currency;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Category {
|
||||
Temperature,
|
||||
Length,
|
||||
Weight,
|
||||
Volume,
|
||||
Speed,
|
||||
Area,
|
||||
Data,
|
||||
Time,
|
||||
Pressure,
|
||||
Energy,
|
||||
Currency,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Conversion {
|
||||
Factor(f64),
|
||||
Custom {
|
||||
to_base: fn(f64) -> f64,
|
||||
from_base: fn(f64) -> f64,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct UnitDef {
|
||||
_id: &'static str,
|
||||
symbol: &'static str,
|
||||
aliases: &'static [&'static str],
|
||||
category: Category,
|
||||
conversion: Conversion,
|
||||
}
|
||||
|
||||
impl UnitDef {
|
||||
fn to_base(&self, value: f64) -> f64 {
|
||||
match &self.conversion {
|
||||
Conversion::Factor(f) => value * f,
|
||||
Conversion::Custom { to_base, .. } => to_base(value),
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_from_base(&self, value: f64) -> f64 {
|
||||
match &self.conversion {
|
||||
Conversion::Factor(f) => value / f,
|
||||
Conversion::Custom { from_base, .. } => from_base(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ConversionResult {
|
||||
pub value: f64,
|
||||
pub raw_value: String,
|
||||
pub display_value: String,
|
||||
pub target_symbol: String,
|
||||
}
|
||||
|
||||
static UNITS: LazyLock<Vec<UnitDef>> = LazyLock::new(build_unit_table);
|
||||
static ALIAS_MAP: LazyLock<HashMap<String, usize>> = LazyLock::new(|| {
|
||||
let mut map = HashMap::new();
|
||||
for (i, unit) in UNITS.iter().enumerate() {
|
||||
for alias in unit.aliases {
|
||||
map.insert(alias.to_lowercase(), i);
|
||||
}
|
||||
}
|
||||
map
|
||||
});
|
||||
|
||||
// Common conversions per category (symbols to show when no target specified)
|
||||
static COMMON_TARGETS: LazyLock<HashMap<Category, Vec<&'static str>>> = LazyLock::new(|| {
|
||||
let mut m = HashMap::new();
|
||||
m.insert(Category::Temperature, vec!["°C", "°F", "K"]);
|
||||
m.insert(Category::Length, vec!["m", "km", "ft", "mi", "in"]);
|
||||
m.insert(Category::Weight, vec!["kg", "lb", "oz", "g", "st"]);
|
||||
m.insert(Category::Volume, vec!["l", "gal", "ml", "cup", "fl oz"]);
|
||||
m.insert(Category::Speed, vec!["km/h", "mph", "m/s", "kn"]);
|
||||
m.insert(Category::Area, vec!["m²", "ft²", "ac", "ha", "km²"]);
|
||||
m.insert(Category::Data, vec!["MB", "GB", "MiB", "GiB", "TB"]);
|
||||
m.insert(Category::Time, vec!["s", "min", "h", "d", "wk"]);
|
||||
m.insert(Category::Pressure, vec!["bar", "psi", "atm", "hPa", "mmHg"]);
|
||||
m.insert(Category::Energy, vec!["kJ", "kcal", "kWh", "BTU", "Wh"]);
|
||||
m.insert(Category::Currency, vec!["USD", "EUR", "GBP", "JPY", "CNY"]);
|
||||
m
|
||||
});
|
||||
|
||||
pub fn find_unit(alias: &str) -> Option<&'static str> {
|
||||
let lower = alias.to_lowercase();
|
||||
if let Some(&i) = ALIAS_MAP.get(&lower) {
|
||||
return Some(UNITS[i].symbol);
|
||||
}
|
||||
currency::resolve_currency_code(&lower)
|
||||
}
|
||||
|
||||
pub fn lookup_unit(alias: &str) -> Option<(usize, &UnitDef)> {
|
||||
let lower = alias.to_lowercase();
|
||||
ALIAS_MAP.get(&lower).map(|&i| (i, &UNITS[i]))
|
||||
}
|
||||
|
||||
pub fn convert_to(value: &f64, from: &str, to: &str) -> Option<ConversionResult> {
|
||||
// Try currency first — currency aliases (dollar, euro, etc.) aren't in the UNITS table
|
||||
if currency::is_currency_alias(from) || currency::is_currency_alias(to) {
|
||||
return convert_currency(*value, from, to);
|
||||
}
|
||||
|
||||
let (_, from_def) = lookup_unit(from)?;
|
||||
let (_, to_def) = lookup_unit(to)?;
|
||||
|
||||
// Currency via UNITS table (shouldn't reach here, but just in case)
|
||||
if from_def.category == Category::Currency || to_def.category == Category::Currency {
|
||||
return convert_currency(*value, from, to);
|
||||
}
|
||||
|
||||
// Must be same category
|
||||
if from_def.category != to_def.category {
|
||||
return None;
|
||||
}
|
||||
|
||||
let base_value = from_def.to_base(*value);
|
||||
let result = to_def.convert_from_base(base_value);
|
||||
|
||||
Some(format_result(result, to_def.symbol))
|
||||
}
|
||||
|
||||
pub fn convert_common(value: &f64, from: &str) -> Vec<ConversionResult> {
|
||||
// Try currency first — currency aliases (dollar, euro, etc.) aren't in the UNITS table
|
||||
if currency::is_currency_alias(from) {
|
||||
return convert_currency_common(*value, from);
|
||||
}
|
||||
|
||||
let (_, from_def) = match lookup_unit(from) {
|
||||
Some(u) => u,
|
||||
None => return vec![],
|
||||
};
|
||||
|
||||
let category = from_def.category;
|
||||
let from_symbol = from_def.symbol;
|
||||
|
||||
if category == Category::Currency {
|
||||
return convert_currency_common(*value, from);
|
||||
}
|
||||
|
||||
let targets = match COMMON_TARGETS.get(&category) {
|
||||
Some(t) => t,
|
||||
None => return vec![],
|
||||
};
|
||||
|
||||
let base_value = from_def.to_base(*value);
|
||||
|
||||
targets
|
||||
.iter()
|
||||
.filter(|&&sym| sym != from_symbol)
|
||||
.filter_map(|&sym| {
|
||||
let (_, to_def) = lookup_unit(sym)?;
|
||||
let result = to_def.convert_from_base(base_value);
|
||||
Some(format_result(result, to_def.symbol))
|
||||
})
|
||||
.take(5)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn convert_currency(value: f64, from: &str, to: &str) -> Option<ConversionResult> {
|
||||
let rates = currency::get_rates()?;
|
||||
let from_code = currency::resolve_currency_code(from)?;
|
||||
let to_code = currency::resolve_currency_code(to)?;
|
||||
|
||||
let from_rate = if from_code == "EUR" { 1.0 } else { *rates.rates.get(from_code)? };
|
||||
let to_rate = if to_code == "EUR" { 1.0 } else { *rates.rates.get(to_code)? };
|
||||
|
||||
let result = value / from_rate * to_rate;
|
||||
Some(format_currency_result(result, to_code))
|
||||
}
|
||||
|
||||
fn convert_currency_common(value: f64, from: &str) -> Vec<ConversionResult> {
|
||||
let rates = match currency::get_rates() {
|
||||
Some(r) => r,
|
||||
None => return vec![],
|
||||
};
|
||||
let from_code = match currency::resolve_currency_code(from) {
|
||||
Some(c) => c,
|
||||
None => return vec![],
|
||||
};
|
||||
|
||||
let targets = COMMON_TARGETS.get(&Category::Currency).unwrap();
|
||||
let from_rate = if from_code == "EUR" {
|
||||
1.0
|
||||
} else {
|
||||
match rates.rates.get(from_code) {
|
||||
Some(&r) => r,
|
||||
None => return vec![],
|
||||
}
|
||||
};
|
||||
|
||||
targets
|
||||
.iter()
|
||||
.filter(|&&sym| sym != from_code)
|
||||
.filter_map(|&sym| {
|
||||
let to_rate = if sym == "EUR" { 1.0 } else { *rates.rates.get(sym)? };
|
||||
let result = value / from_rate * to_rate;
|
||||
Some(format_currency_result(result, sym))
|
||||
})
|
||||
.take(5)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn format_result(value: f64, symbol: &str) -> ConversionResult {
|
||||
let raw = if value.fract() == 0.0 && value.abs() < 1e15 {
|
||||
format!("{}", value as i64)
|
||||
} else {
|
||||
format!("{:.4}", value)
|
||||
.trim_end_matches('0')
|
||||
.trim_end_matches('.')
|
||||
.to_string()
|
||||
};
|
||||
|
||||
let display = if value.abs() >= 1000.0 && value.fract() == 0.0 && value.abs() < 1e15 {
|
||||
super::format_with_separators(value as i64)
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
|
||||
ConversionResult {
|
||||
value,
|
||||
raw_value: raw,
|
||||
display_value: format!("{} {}", display, symbol),
|
||||
target_symbol: symbol.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_currency_result(value: f64, code: &str) -> ConversionResult {
|
||||
let raw = format!("{:.2}", value);
|
||||
let display = raw.clone();
|
||||
|
||||
ConversionResult {
|
||||
value,
|
||||
raw_value: raw,
|
||||
display_value: format!("{} {}", display, code),
|
||||
target_symbol: code.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_unit_table() -> Vec<UnitDef> {
|
||||
vec![
|
||||
// Temperature (base: Kelvin)
|
||||
UnitDef {
|
||||
_id: "celsius",
|
||||
symbol: "°C",
|
||||
aliases: &["c", "°c", "celsius", "degc", "centigrade"],
|
||||
category: Category::Temperature,
|
||||
conversion: Conversion::Custom {
|
||||
to_base: |v| v + 273.15,
|
||||
from_base: |v| v - 273.15,
|
||||
},
|
||||
},
|
||||
UnitDef {
|
||||
_id: "fahrenheit",
|
||||
symbol: "°F",
|
||||
aliases: &["f", "°f", "fahrenheit", "degf"],
|
||||
category: Category::Temperature,
|
||||
conversion: Conversion::Custom {
|
||||
to_base: |v| (v - 32.0) * 5.0 / 9.0 + 273.15,
|
||||
from_base: |v| (v - 273.15) * 9.0 / 5.0 + 32.0,
|
||||
},
|
||||
},
|
||||
UnitDef {
|
||||
_id: "kelvin",
|
||||
symbol: "K",
|
||||
aliases: &["k", "kelvin"],
|
||||
category: Category::Temperature,
|
||||
conversion: Conversion::Factor(1.0), // base
|
||||
},
|
||||
// Length (base: meter)
|
||||
UnitDef {
|
||||
_id: "millimeter",
|
||||
symbol: "mm",
|
||||
aliases: &["mm", "millimeter", "millimeters", "millimetre"],
|
||||
category: Category::Length,
|
||||
conversion: Conversion::Factor(0.001),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "centimeter",
|
||||
symbol: "cm",
|
||||
aliases: &["cm", "centimeter", "centimeters", "centimetre"],
|
||||
category: Category::Length,
|
||||
conversion: Conversion::Factor(0.01),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "meter",
|
||||
symbol: "m",
|
||||
aliases: &["m", "meter", "meters", "metre", "metres"],
|
||||
category: Category::Length,
|
||||
conversion: Conversion::Factor(1.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "kilometer",
|
||||
symbol: "km",
|
||||
aliases: &["km", "kms", "kilometer", "kilometers", "kilometre"],
|
||||
category: Category::Length,
|
||||
conversion: Conversion::Factor(1000.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "inch",
|
||||
symbol: "in",
|
||||
aliases: &["in", "inch", "inches"],
|
||||
category: Category::Length,
|
||||
conversion: Conversion::Factor(0.0254),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "foot",
|
||||
symbol: "ft",
|
||||
aliases: &["ft", "foot", "feet"],
|
||||
category: Category::Length,
|
||||
conversion: Conversion::Factor(0.3048),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "yard",
|
||||
symbol: "yd",
|
||||
aliases: &["yd", "yard", "yards"],
|
||||
category: Category::Length,
|
||||
conversion: Conversion::Factor(0.9144),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "mile",
|
||||
symbol: "mi",
|
||||
aliases: &["mi", "mile", "miles"],
|
||||
category: Category::Length,
|
||||
conversion: Conversion::Factor(1609.344),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "nautical_mile",
|
||||
symbol: "nmi",
|
||||
aliases: &["nmi", "nautical_mile", "nautical_miles"],
|
||||
category: Category::Length,
|
||||
conversion: Conversion::Factor(1852.0),
|
||||
},
|
||||
// Weight (base: kg)
|
||||
UnitDef {
|
||||
_id: "milligram",
|
||||
symbol: "mg",
|
||||
aliases: &["mg", "milligram", "milligrams"],
|
||||
category: Category::Weight,
|
||||
conversion: Conversion::Factor(0.000001),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "gram",
|
||||
symbol: "g",
|
||||
aliases: &["g", "gram", "grams"],
|
||||
category: Category::Weight,
|
||||
conversion: Conversion::Factor(0.001),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "kilogram",
|
||||
symbol: "kg",
|
||||
aliases: &["kg", "kilogram", "kilograms", "kilo", "kilos"],
|
||||
category: Category::Weight,
|
||||
conversion: Conversion::Factor(1.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "tonne",
|
||||
symbol: "t",
|
||||
aliases: &["t", "ton", "tons", "tonne", "tonnes", "metric_ton"],
|
||||
category: Category::Weight,
|
||||
conversion: Conversion::Factor(1000.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "short_ton",
|
||||
symbol: "short_ton",
|
||||
aliases: &["short_ton", "ton_us"],
|
||||
category: Category::Weight,
|
||||
conversion: Conversion::Factor(907.185),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "ounce",
|
||||
symbol: "oz",
|
||||
aliases: &["oz", "ounce", "ounces"],
|
||||
category: Category::Weight,
|
||||
conversion: Conversion::Factor(0.0283495),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "pound",
|
||||
symbol: "lb",
|
||||
aliases: &["lb", "lbs", "pound", "pounds"],
|
||||
category: Category::Weight,
|
||||
conversion: Conversion::Factor(0.453592),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "stone",
|
||||
symbol: "st",
|
||||
aliases: &["st", "stone", "stones"],
|
||||
category: Category::Weight,
|
||||
conversion: Conversion::Factor(6.35029),
|
||||
},
|
||||
// Volume (base: liter)
|
||||
UnitDef {
|
||||
_id: "milliliter",
|
||||
symbol: "ml",
|
||||
aliases: &["ml", "milliliter", "milliliters", "millilitre"],
|
||||
category: Category::Volume,
|
||||
conversion: Conversion::Factor(0.001),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "liter",
|
||||
symbol: "l",
|
||||
aliases: &["l", "liter", "liters", "litre", "litres"],
|
||||
category: Category::Volume,
|
||||
conversion: Conversion::Factor(1.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "us_gallon",
|
||||
symbol: "gal",
|
||||
aliases: &["gal", "gallon", "gallons"],
|
||||
category: Category::Volume,
|
||||
conversion: Conversion::Factor(3.78541),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "imp_gallon",
|
||||
symbol: "imp gal",
|
||||
aliases: &["imp_gal", "gal_uk", "imperial_gallon"],
|
||||
category: Category::Volume,
|
||||
conversion: Conversion::Factor(4.54609),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "quart",
|
||||
symbol: "qt",
|
||||
aliases: &["qt", "quart", "quarts"],
|
||||
category: Category::Volume,
|
||||
conversion: Conversion::Factor(0.946353),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "pint",
|
||||
symbol: "pt",
|
||||
aliases: &["pt", "pint", "pints"],
|
||||
category: Category::Volume,
|
||||
conversion: Conversion::Factor(0.473176),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "cup",
|
||||
symbol: "cup",
|
||||
aliases: &["cup", "cups"],
|
||||
category: Category::Volume,
|
||||
conversion: Conversion::Factor(0.236588),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "fluid_ounce",
|
||||
symbol: "fl oz",
|
||||
aliases: &["floz", "fl_oz", "fluid_ounce", "fluid_ounces"],
|
||||
category: Category::Volume,
|
||||
conversion: Conversion::Factor(0.0295735),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "tablespoon",
|
||||
symbol: "tbsp",
|
||||
aliases: &["tbsp", "tablespoon", "tablespoons"],
|
||||
category: Category::Volume,
|
||||
conversion: Conversion::Factor(0.0147868),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "teaspoon",
|
||||
symbol: "tsp",
|
||||
aliases: &["tsp", "teaspoon", "teaspoons"],
|
||||
category: Category::Volume,
|
||||
conversion: Conversion::Factor(0.00492892),
|
||||
},
|
||||
// Speed (base: m/s)
|
||||
UnitDef {
|
||||
_id: "mps",
|
||||
symbol: "m/s",
|
||||
aliases: &["m/s", "mps", "meters_per_second"],
|
||||
category: Category::Speed,
|
||||
conversion: Conversion::Factor(1.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "kmh",
|
||||
symbol: "km/h",
|
||||
aliases: &["km/h", "kmh", "kph", "kilometers_per_hour"],
|
||||
category: Category::Speed,
|
||||
conversion: Conversion::Factor(0.277778),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "mph",
|
||||
symbol: "mph",
|
||||
aliases: &["mph", "miles_per_hour"],
|
||||
category: Category::Speed,
|
||||
conversion: Conversion::Factor(0.44704),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "knot",
|
||||
symbol: "kn",
|
||||
aliases: &["kn", "kt", "knot", "knots"],
|
||||
category: Category::Speed,
|
||||
conversion: Conversion::Factor(0.514444),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "fps",
|
||||
symbol: "ft/s",
|
||||
aliases: &["ft/s", "fps", "feet_per_second"],
|
||||
category: Category::Speed,
|
||||
conversion: Conversion::Factor(0.3048),
|
||||
},
|
||||
// Area (base: m²)
|
||||
UnitDef {
|
||||
_id: "sqmm",
|
||||
symbol: "mm²",
|
||||
aliases: &["mm2", "sqmm", "square_millimeter"],
|
||||
category: Category::Area,
|
||||
conversion: Conversion::Factor(0.000001),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "sqcm",
|
||||
symbol: "cm²",
|
||||
aliases: &["cm2", "sqcm", "square_centimeter"],
|
||||
category: Category::Area,
|
||||
conversion: Conversion::Factor(0.0001),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "sqm",
|
||||
symbol: "m²",
|
||||
aliases: &["m2", "sqm", "square_meter", "square_meters"],
|
||||
category: Category::Area,
|
||||
conversion: Conversion::Factor(1.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "sqkm",
|
||||
symbol: "km²",
|
||||
aliases: &["km2", "sqkm", "square_kilometer"],
|
||||
category: Category::Area,
|
||||
conversion: Conversion::Factor(1000000.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "sqft",
|
||||
symbol: "ft²",
|
||||
aliases: &["ft2", "sqft", "square_foot", "square_feet"],
|
||||
category: Category::Area,
|
||||
conversion: Conversion::Factor(0.092903),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "acre",
|
||||
symbol: "ac",
|
||||
aliases: &["ac", "acre", "acres"],
|
||||
category: Category::Area,
|
||||
conversion: Conversion::Factor(4046.86),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "hectare",
|
||||
symbol: "ha",
|
||||
aliases: &["ha", "hectare", "hectares"],
|
||||
category: Category::Area,
|
||||
conversion: Conversion::Factor(10000.0),
|
||||
},
|
||||
// Data (base: byte)
|
||||
UnitDef {
|
||||
_id: "byte",
|
||||
symbol: "B",
|
||||
aliases: &["b", "byte", "bytes"],
|
||||
category: Category::Data,
|
||||
conversion: Conversion::Factor(1.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "kilobyte",
|
||||
symbol: "KB",
|
||||
aliases: &["kb", "kilobyte", "kilobytes"],
|
||||
category: Category::Data,
|
||||
conversion: Conversion::Factor(1000.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "megabyte",
|
||||
symbol: "MB",
|
||||
aliases: &["mb", "megabyte", "megabytes"],
|
||||
category: Category::Data,
|
||||
conversion: Conversion::Factor(1_000_000.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "gigabyte",
|
||||
symbol: "GB",
|
||||
aliases: &["gb", "gigabyte", "gigabytes"],
|
||||
category: Category::Data,
|
||||
conversion: Conversion::Factor(1_000_000_000.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "terabyte",
|
||||
symbol: "TB",
|
||||
aliases: &["tb", "terabyte", "terabytes"],
|
||||
category: Category::Data,
|
||||
conversion: Conversion::Factor(1_000_000_000_000.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "kibibyte",
|
||||
symbol: "KiB",
|
||||
aliases: &["kib", "kibibyte", "kibibytes"],
|
||||
category: Category::Data,
|
||||
conversion: Conversion::Factor(1024.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "mebibyte",
|
||||
symbol: "MiB",
|
||||
aliases: &["mib", "mebibyte", "mebibytes"],
|
||||
category: Category::Data,
|
||||
conversion: Conversion::Factor(1_048_576.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "gibibyte",
|
||||
symbol: "GiB",
|
||||
aliases: &["gib", "gibibyte", "gibibytes"],
|
||||
category: Category::Data,
|
||||
conversion: Conversion::Factor(1_073_741_824.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "tebibyte",
|
||||
symbol: "TiB",
|
||||
aliases: &["tib", "tebibyte", "tebibytes"],
|
||||
category: Category::Data,
|
||||
conversion: Conversion::Factor(1_099_511_627_776.0),
|
||||
},
|
||||
// Time (base: second)
|
||||
UnitDef {
|
||||
_id: "second",
|
||||
symbol: "s",
|
||||
aliases: &["s", "sec", "second", "seconds"],
|
||||
category: Category::Time,
|
||||
conversion: Conversion::Factor(1.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "minute",
|
||||
symbol: "min",
|
||||
aliases: &["min", "minute", "minutes"],
|
||||
category: Category::Time,
|
||||
conversion: Conversion::Factor(60.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "hour",
|
||||
symbol: "h",
|
||||
aliases: &["h", "hr", "hour", "hours"],
|
||||
category: Category::Time,
|
||||
conversion: Conversion::Factor(3600.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "day",
|
||||
symbol: "d",
|
||||
aliases: &["d", "day", "days"],
|
||||
category: Category::Time,
|
||||
conversion: Conversion::Factor(86400.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "week",
|
||||
symbol: "wk",
|
||||
aliases: &["wk", "week", "weeks"],
|
||||
category: Category::Time,
|
||||
conversion: Conversion::Factor(604800.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "month",
|
||||
symbol: "mo",
|
||||
aliases: &["mo", "month", "months"],
|
||||
category: Category::Time,
|
||||
conversion: Conversion::Factor(2_592_000.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "year",
|
||||
symbol: "yr",
|
||||
aliases: &["yr", "year", "years"],
|
||||
category: Category::Time,
|
||||
conversion: Conversion::Factor(31_536_000.0),
|
||||
},
|
||||
// Pressure (base: Pa)
|
||||
UnitDef {
|
||||
_id: "pascal",
|
||||
symbol: "Pa",
|
||||
aliases: &["pa", "pascal", "pascals"],
|
||||
category: Category::Pressure,
|
||||
conversion: Conversion::Factor(1.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "hectopascal",
|
||||
symbol: "hPa",
|
||||
aliases: &["hpa", "hectopascal"],
|
||||
category: Category::Pressure,
|
||||
conversion: Conversion::Factor(100.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "kilopascal",
|
||||
symbol: "kPa",
|
||||
aliases: &["kpa", "kilopascal"],
|
||||
category: Category::Pressure,
|
||||
conversion: Conversion::Factor(1000.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "bar",
|
||||
symbol: "bar",
|
||||
aliases: &["bar", "bars"],
|
||||
category: Category::Pressure,
|
||||
conversion: Conversion::Factor(100_000.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "millibar",
|
||||
symbol: "mbar",
|
||||
aliases: &["mbar", "millibar"],
|
||||
category: Category::Pressure,
|
||||
conversion: Conversion::Factor(100.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "psi",
|
||||
symbol: "psi",
|
||||
aliases: &["psi", "pounds_per_square_inch"],
|
||||
category: Category::Pressure,
|
||||
conversion: Conversion::Factor(6894.76),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "atmosphere",
|
||||
symbol: "atm",
|
||||
aliases: &["atm", "atmosphere", "atmospheres"],
|
||||
category: Category::Pressure,
|
||||
conversion: Conversion::Factor(101_325.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "mmhg",
|
||||
symbol: "mmHg",
|
||||
aliases: &["mmhg", "torr"],
|
||||
category: Category::Pressure,
|
||||
conversion: Conversion::Factor(133.322),
|
||||
},
|
||||
// Energy (base: Joule)
|
||||
UnitDef {
|
||||
_id: "joule",
|
||||
symbol: "J",
|
||||
aliases: &["j", "joule", "joules"],
|
||||
category: Category::Energy,
|
||||
conversion: Conversion::Factor(1.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "kilojoule",
|
||||
symbol: "kJ",
|
||||
aliases: &["kj", "kilojoule", "kilojoules"],
|
||||
category: Category::Energy,
|
||||
conversion: Conversion::Factor(1000.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "calorie",
|
||||
symbol: "cal",
|
||||
aliases: &["cal", "calorie", "calories"],
|
||||
category: Category::Energy,
|
||||
conversion: Conversion::Factor(4.184),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "kilocalorie",
|
||||
symbol: "kcal",
|
||||
aliases: &["kcal", "kilocalorie", "kilocalories"],
|
||||
category: Category::Energy,
|
||||
conversion: Conversion::Factor(4184.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "watt_hour",
|
||||
symbol: "Wh",
|
||||
aliases: &["wh", "watt_hour"],
|
||||
category: Category::Energy,
|
||||
conversion: Conversion::Factor(3600.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "kilowatt_hour",
|
||||
symbol: "kWh",
|
||||
aliases: &["kwh", "kilowatt_hour"],
|
||||
category: Category::Energy,
|
||||
conversion: Conversion::Factor(3_600_000.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "btu",
|
||||
symbol: "BTU",
|
||||
aliases: &["btu", "british_thermal_unit"],
|
||||
category: Category::Energy,
|
||||
conversion: Conversion::Factor(1055.06),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_celsius_to_fahrenheit() {
|
||||
let r = convert_to(&100.0, "c", "f").unwrap();
|
||||
assert!((r.value - 212.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fahrenheit_to_celsius() {
|
||||
let r = convert_to(&32.0, "f", "c").unwrap();
|
||||
assert!((r.value - 0.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_body_temp_f_to_c() {
|
||||
let r = convert_to(&98.6, "f", "c").unwrap();
|
||||
assert!((r.value - 37.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_km_to_miles() {
|
||||
let r = convert_to(&100.0, "km", "mi").unwrap();
|
||||
assert!((r.value - 62.1371).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_miles_to_km() {
|
||||
let r = convert_to(&1.0, "mi", "km").unwrap();
|
||||
assert!((r.value - 1.60934).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_kg_to_lb() {
|
||||
let r = convert_to(&1.0, "kg", "lb").unwrap();
|
||||
assert!((r.value - 2.20462).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lb_to_kg() {
|
||||
let r = convert_to(&100.0, "lbs", "kg").unwrap();
|
||||
assert!((r.value - 45.3592).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_liters_to_gallons() {
|
||||
let r = convert_to(&3.78541, "l", "gal").unwrap();
|
||||
assert!((r.value - 1.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_kmh_to_mph() {
|
||||
let r = convert_to(&100.0, "kmh", "mph").unwrap();
|
||||
assert!((r.value - 62.1371).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gb_to_mb() {
|
||||
let r = convert_to(&1.0, "gb", "mb").unwrap();
|
||||
assert!((r.value - 1000.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gib_to_mib() {
|
||||
let r = convert_to(&1.0, "gib", "mib").unwrap();
|
||||
assert!((r.value - 1024.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hours_to_minutes() {
|
||||
let r = convert_to(&2.5, "h", "min").unwrap();
|
||||
assert!((r.value - 150.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bar_to_psi() {
|
||||
let r = convert_to(&1.0, "bar", "psi").unwrap();
|
||||
assert!((r.value - 14.5038).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_kcal_to_kj() {
|
||||
let r = convert_to(&1.0, "kcal", "kj").unwrap();
|
||||
assert!((r.value - 4.184).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sqm_to_sqft() {
|
||||
let r = convert_to(&1.0, "m2", "ft2").unwrap();
|
||||
assert!((r.value - 10.7639).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_unit_returns_none() {
|
||||
assert!(convert_to(&100.0, "xyz", "abc").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cross_category_returns_none() {
|
||||
assert!(convert_to(&100.0, "km", "kg").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_common_returns_results() {
|
||||
let results = convert_common(&100.0, "km");
|
||||
assert!(!results.is_empty());
|
||||
assert!(results.len() <= 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_common_excludes_source() {
|
||||
let results = convert_common(&100.0, "km");
|
||||
for r in &results {
|
||||
assert_ne!(r.target_symbol, "km");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_alias_case_insensitive() {
|
||||
let r1 = convert_to(&100.0, "KM", "MI").unwrap();
|
||||
let r2 = convert_to(&100.0, "km", "mi").unwrap();
|
||||
assert!((r1.value - r2.value).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_name_alias() {
|
||||
let r = convert_to(&100.0, "kilometers", "miles").unwrap();
|
||||
assert!((r.value - 62.1371).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_currency_two_decimals() {
|
||||
let r = convert_to(&1.0, "km", "mi").unwrap();
|
||||
// display_value should have reasonable formatting
|
||||
assert!(!r.display_value.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_currency_alias_convert_to() {
|
||||
// "dollar" and "euro" are aliases, not in the UNITS table
|
||||
let r = convert_to(&20.0, "dollar", "euro");
|
||||
// May return None if ECB rates unavailable (network), but should not panic
|
||||
// In a network-available environment, this should return Some
|
||||
if let Some(r) = r {
|
||||
assert!(r.value > 0.0);
|
||||
assert_eq!(r.target_symbol, "EUR");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_currency_alias_convert_common() {
|
||||
let results = convert_common(&20.0, "dollar");
|
||||
// May be empty if ECB rates unavailable, but should not panic
|
||||
for r in &results {
|
||||
assert!(r.value > 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_display_value_no_double_unit() {
|
||||
let r = convert_to(&100.0, "km", "mi").unwrap();
|
||||
// display_value should contain the symbol exactly once
|
||||
let count = r.display_value.matches(&r.target_symbol).count();
|
||||
assert_eq!(count, 1, "display_value '{}' should contain '{}' exactly once", r.display_value, r.target_symbol);
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,11 @@
|
||||
//! This module provides a `LuaProvider` struct that implements the `Provider` trait
|
||||
//! by delegating to a Lua plugin's registered provider functions.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::plugins::{LoadedPlugin, PluginItem, ProviderRegistration};
|
||||
|
||||
use super::{LaunchItem, Provider, ProviderType};
|
||||
use super::{ItemSource, LaunchItem, Provider, ProviderType};
|
||||
|
||||
/// A provider backed by a Lua plugin
|
||||
///
|
||||
@@ -17,15 +16,16 @@ use super::{LaunchItem, Provider, ProviderType};
|
||||
pub struct LuaProvider {
|
||||
/// Provider registration info
|
||||
registration: ProviderRegistration,
|
||||
/// Reference to the loaded plugin (shared with other providers from same plugin)
|
||||
plugin: Rc<RefCell<LoadedPlugin>>,
|
||||
/// Reference to the loaded plugin (shared with other providers from same plugin).
|
||||
/// Mutex serializes concurrent refresh calls; Arc allows sharing across threads.
|
||||
plugin: Arc<Mutex<LoadedPlugin>>,
|
||||
/// Cached items from last refresh
|
||||
items: Vec<LaunchItem>,
|
||||
}
|
||||
|
||||
impl LuaProvider {
|
||||
/// Create a new LuaProvider
|
||||
pub fn new(registration: ProviderRegistration, plugin: Rc<RefCell<LoadedPlugin>>) -> Self {
|
||||
pub fn new(registration: ProviderRegistration, plugin: Arc<Mutex<LoadedPlugin>>) -> Self {
|
||||
Self {
|
||||
registration,
|
||||
plugin,
|
||||
@@ -35,6 +35,9 @@ impl LuaProvider {
|
||||
|
||||
/// Convert a PluginItem to a LaunchItem
|
||||
fn convert_item(&self, item: PluginItem) -> LaunchItem {
|
||||
if item.command.is_none() {
|
||||
log::warn!("Plugin item '{}' has no command", item.name);
|
||||
}
|
||||
LaunchItem {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
@@ -44,6 +47,7 @@ impl LuaProvider {
|
||||
command: item.command.unwrap_or_default(),
|
||||
terminal: item.terminal,
|
||||
tags: item.tags,
|
||||
source: ItemSource::ScriptPlugin,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,7 +67,7 @@ impl Provider for LuaProvider {
|
||||
return;
|
||||
}
|
||||
|
||||
let plugin = self.plugin.borrow();
|
||||
let plugin = self.plugin.lock().unwrap();
|
||||
match plugin.call_provider_refresh(&self.registration.name) {
|
||||
Ok(items) => {
|
||||
self.items = items.into_iter().map(|i| self.convert_item(i)).collect();
|
||||
@@ -89,17 +93,15 @@ impl Provider for LuaProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// LuaProvider needs to be Send + Sync for the Provider trait.
|
||||
// Rc<RefCell<>> is !Send and !Sync, but the ProviderManager RwLock ensures
|
||||
// Rc<RefCell<>> is only accessed during refresh() (write lock = exclusive access).
|
||||
// Read-only operations (items(), search) only touch self.items (Vec<LaunchItem>).
|
||||
unsafe impl Send for LuaProvider {}
|
||||
unsafe impl Sync for LuaProvider {}
|
||||
// LuaProvider is Send + Sync because:
|
||||
// - Arc<Mutex<LoadedPlugin>> is Send + Sync (LoadedPlugin: Send with mlua "send" feature)
|
||||
// - All other fields are Send + Sync
|
||||
// No unsafe impl needed.
|
||||
|
||||
/// 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: Arc<Mutex<LoadedPlugin>>) -> Vec<Box<dyn Provider>> {
|
||||
let registrations = {
|
||||
let p = plugin.borrow();
|
||||
let p = plugin.lock().unwrap();
|
||||
match p.get_provider_registrations() {
|
||||
Ok(regs) => regs,
|
||||
Err(e) => {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// Core providers (no plugin equivalents)
|
||||
mod application;
|
||||
mod command;
|
||||
pub(crate) mod calculator;
|
||||
pub(crate) mod config_editor;
|
||||
pub(crate) mod converter;
|
||||
pub(crate) mod system;
|
||||
|
||||
// Native plugin bridge
|
||||
pub mod native_provider;
|
||||
@@ -16,13 +20,16 @@ pub use command::CommandProvider;
|
||||
// Re-export native provider for plugin loading
|
||||
pub use native_provider::NativeProvider;
|
||||
|
||||
use chrono::Utc;
|
||||
use fuzzy_matcher::FuzzyMatcher;
|
||||
use fuzzy_matcher::skim::SkimMatcherV2;
|
||||
use log::info;
|
||||
use log::{info, warn};
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
use log::debug;
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::data::FrecencyStore;
|
||||
use crate::plugins::runtime_loader::LoadedRuntime;
|
||||
@@ -37,6 +44,38 @@ pub struct ProviderDescriptor {
|
||||
pub position: String,
|
||||
}
|
||||
|
||||
/// Trust level of a [`LaunchItem`]'s command, used to gate `sh -c` execution.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ItemSource {
|
||||
/// Built-in provider compiled into owlry-core (trusted).
|
||||
Core,
|
||||
/// Native plugin (.so from /usr/lib/owlry/plugins/) — trusted at install time.
|
||||
NativePlugin,
|
||||
/// Script plugin (Lua/Rune from ~/.config/owlry/plugins/) — user-installed, untrusted.
|
||||
ScriptPlugin,
|
||||
}
|
||||
|
||||
impl ItemSource {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ItemSource::Core => "core",
|
||||
ItemSource::NativePlugin => "native_plugin",
|
||||
ItemSource::ScriptPlugin => "script_plugin",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for ItemSource {
|
||||
type Err = ();
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"native_plugin" => Ok(ItemSource::NativePlugin),
|
||||
"script_plugin" => Ok(ItemSource::ScriptPlugin),
|
||||
_ => Ok(ItemSource::Core),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a single searchable/launchable item
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LaunchItem {
|
||||
@@ -50,21 +89,29 @@ pub struct LaunchItem {
|
||||
pub terminal: bool,
|
||||
/// Tags/categories for filtering (e.g., from .desktop Categories)
|
||||
pub tags: Vec<String>,
|
||||
/// Trust level — gates `sh -c` execution for script plugin items.
|
||||
pub source: ItemSource,
|
||||
}
|
||||
|
||||
/// Provider type identifier for filtering and badge display
|
||||
/// 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.
|
||||
/// **Glossary:**
|
||||
/// - *Provider*: An abstract source of [`LaunchItem`]s (interface).
|
||||
/// - *Built-in provider*: A provider compiled into owlry-core (Application, Command).
|
||||
/// - *Plugin*: External code (native `.so` or Lua/Rune script) loaded at runtime.
|
||||
/// - *Plugin provider*: A provider registered by a plugin, identified by its `type_id`.
|
||||
///
|
||||
/// All plugin-provided types use `Plugin(type_id)`. The core has no hardcoded
|
||||
/// knowledge of individual plugin types — this keeps the core app extensible.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum ProviderType {
|
||||
/// Built-in: Desktop applications from XDG directories
|
||||
/// Built-in provider: desktop applications from XDG data directories.
|
||||
Application,
|
||||
/// Built-in: Shell commands from PATH
|
||||
/// Built-in provider: shell commands from `$PATH`.
|
||||
Command,
|
||||
/// Built-in: Pipe-based input (dmenu compatibility)
|
||||
/// Built-in provider: pipe-based input for dmenu compatibility (client-local only).
|
||||
Dmenu,
|
||||
/// Plugin-defined provider type with its type_id (e.g., "calc", "weather", "emoji")
|
||||
/// Plugin provider with its declared `type_id` (e.g. `"calc"`, `"weather"`, `"emoji"`).
|
||||
Plugin(String),
|
||||
}
|
||||
|
||||
@@ -103,10 +150,29 @@ pub trait Provider: Send + Sync {
|
||||
fn items(&self) -> &[LaunchItem];
|
||||
}
|
||||
|
||||
/// Trait for built-in providers that produce results per-keystroke.
|
||||
/// Unlike static `Provider`s which cache items via `refresh()`/`items()`,
|
||||
/// dynamic providers generate results on every query.
|
||||
pub(crate) trait DynamicProvider: Send + Sync {
|
||||
#[allow(dead_code)]
|
||||
fn name(&self) -> &str;
|
||||
fn provider_type(&self) -> ProviderType;
|
||||
fn query(&self, query: &str) -> Vec<LaunchItem>;
|
||||
fn priority(&self) -> u32;
|
||||
|
||||
/// Handle a plugin action command. Returns true if handled.
|
||||
fn execute_action(&self, _command: &str) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages all providers and handles searching
|
||||
pub struct ProviderManager {
|
||||
/// Core static providers (apps, commands, dmenu)
|
||||
providers: Vec<Box<dyn Provider>>,
|
||||
/// Built-in dynamic providers (calculator, converter)
|
||||
/// These are queried per-keystroke, like native dynamic plugins
|
||||
builtin_dynamic: Vec<Box<dyn DynamicProvider>>,
|
||||
/// Static native plugin providers (need query() for submenu support)
|
||||
static_native_providers: Vec<NativeProvider>,
|
||||
/// Dynamic providers from native plugins (calculator, websearch, filesearch)
|
||||
@@ -121,6 +187,9 @@ pub struct ProviderManager {
|
||||
runtimes: Vec<LoadedRuntime>,
|
||||
/// Type IDs of providers from script runtimes (for hot-reload removal)
|
||||
runtime_type_ids: std::collections::HashSet<String>,
|
||||
/// Registry of native plugins that were loaded or suppressed at startup.
|
||||
/// Used by `Request::PluginList` to report plugin status to the CLI.
|
||||
pub plugin_registry: Vec<crate::ipc::PluginEntry>,
|
||||
}
|
||||
|
||||
impl ProviderManager {
|
||||
@@ -135,12 +204,14 @@ impl ProviderManager {
|
||||
) -> Self {
|
||||
let mut manager = Self {
|
||||
providers: core_providers,
|
||||
builtin_dynamic: Vec::new(),
|
||||
static_native_providers: Vec::new(),
|
||||
dynamic_providers: Vec::new(),
|
||||
widget_providers: Vec::new(),
|
||||
matcher: SkimMatcherV2::default(),
|
||||
runtimes: Vec::new(),
|
||||
runtime_type_ids: std::collections::HashSet::new(),
|
||||
plugin_registry: Vec::new(),
|
||||
};
|
||||
|
||||
// Categorize native plugins based on their declared ProviderKind and ProviderPosition
|
||||
@@ -182,9 +253,8 @@ impl ProviderManager {
|
||||
/// 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 {
|
||||
pub fn new_with_config(config: Arc<RwLock<Config>>) -> Self {
|
||||
use crate::plugins::native_loader::NativePluginLoader;
|
||||
use std::sync::Arc;
|
||||
|
||||
// Create core providers
|
||||
let mut core_providers: Vec<Box<dyn Provider>> = vec![
|
||||
@@ -192,9 +262,23 @@ impl ProviderManager {
|
||||
Box::new(CommandProvider::new()),
|
||||
];
|
||||
|
||||
// Take a read lock once for configuration reads during setup.
|
||||
let (disabled_plugins, calc_enabled, conv_enabled, sys_enabled) = match config.read() {
|
||||
Ok(cfg) => (
|
||||
cfg.plugins.disabled_plugins.clone(),
|
||||
cfg.providers.calculator,
|
||||
cfg.providers.converter,
|
||||
cfg.providers.system,
|
||||
),
|
||||
Err(_) => {
|
||||
warn!("Config lock poisoned during provider init; using defaults");
|
||||
(Vec::new(), true, true, true)
|
||||
}
|
||||
};
|
||||
|
||||
// Load native plugins
|
||||
let mut loader = NativePluginLoader::new();
|
||||
loader.set_disabled(config.plugins.disabled_plugins.clone());
|
||||
loader.set_disabled(disabled_plugins);
|
||||
|
||||
let native_providers = match loader.discover() {
|
||||
Ok(count) => {
|
||||
@@ -276,9 +360,100 @@ impl ProviderManager {
|
||||
core_providers.push(provider);
|
||||
}
|
||||
|
||||
// Built-in dynamic providers
|
||||
let mut builtin_dynamic: Vec<Box<dyn DynamicProvider>> = Vec::new();
|
||||
|
||||
if calc_enabled {
|
||||
builtin_dynamic.push(Box::new(calculator::CalculatorProvider));
|
||||
info!("Registered built-in calculator provider");
|
||||
}
|
||||
|
||||
if conv_enabled {
|
||||
builtin_dynamic.push(Box::new(converter::ConverterProvider::new()));
|
||||
info!("Registered built-in converter provider");
|
||||
}
|
||||
|
||||
// Config editor — always enabled; shares the same Arc<RwLock<Config>>
|
||||
builtin_dynamic.push(Box::new(config_editor::ConfigProvider::new(Arc::clone(&config))));
|
||||
info!("Registered built-in config editor provider");
|
||||
|
||||
// Built-in static providers
|
||||
if sys_enabled {
|
||||
core_providers.push(Box::new(system::SystemProvider::new()));
|
||||
info!("Registered built-in system provider");
|
||||
}
|
||||
|
||||
// Compute built-in type IDs to detect conflicts with native plugins.
|
||||
// A native plugin whose type_id matches a built-in provider would
|
||||
// produce duplicate results, so we skip it.
|
||||
let builtin_ids: std::collections::HashSet<String> = {
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
// Dynamic built-ins (calculator, converter)
|
||||
for p in &builtin_dynamic {
|
||||
if let ProviderType::Plugin(id) = p.provider_type() {
|
||||
ids.insert(id);
|
||||
}
|
||||
}
|
||||
// Static built-ins added to core_providers (e.g. system)
|
||||
for p in &core_providers {
|
||||
if let ProviderType::Plugin(id) = p.provider_type() {
|
||||
ids.insert(id);
|
||||
}
|
||||
}
|
||||
ids
|
||||
};
|
||||
|
||||
let mut suppressed_registry: Vec<crate::ipc::PluginEntry> = Vec::new();
|
||||
let native_providers: Vec<NativeProvider> = native_providers
|
||||
.into_iter()
|
||||
.filter(|provider| {
|
||||
let type_id = provider.type_id();
|
||||
if builtin_ids.contains(type_id) {
|
||||
log::warn!(
|
||||
"Native plugin '{}' suppressed — a built-in provider with the same type ID exists",
|
||||
type_id
|
||||
);
|
||||
suppressed_registry.push(crate::ipc::PluginEntry {
|
||||
id: provider.plugin_id().to_string(),
|
||||
name: provider.plugin_name().to_string(),
|
||||
version: provider.plugin_version().to_string(),
|
||||
runtime: "native".to_string(),
|
||||
status: "suppressed".to_string(),
|
||||
status_detail: format!(
|
||||
"built-in provider '{}' takes precedence",
|
||||
type_id
|
||||
),
|
||||
providers: vec![type_id.to_string()],
|
||||
});
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Capture active native plugin entries before ownership moves into Self::new().
|
||||
let active_registry: Vec<crate::ipc::PluginEntry> = native_providers
|
||||
.iter()
|
||||
.map(|p| crate::ipc::PluginEntry {
|
||||
id: p.plugin_id().to_string(),
|
||||
name: p.plugin_name().to_string(),
|
||||
version: p.plugin_version().to_string(),
|
||||
runtime: "native".to_string(),
|
||||
status: "active".to_string(),
|
||||
status_detail: String::new(),
|
||||
providers: vec![p.type_id().to_string()],
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut manager = Self::new(core_providers, native_providers);
|
||||
manager.builtin_dynamic = builtin_dynamic;
|
||||
manager.runtimes = runtimes;
|
||||
manager.runtime_type_ids = runtime_type_ids;
|
||||
|
||||
manager.plugin_registry = active_registry;
|
||||
manager.plugin_registry.extend(suppressed_registry);
|
||||
|
||||
manager
|
||||
}
|
||||
|
||||
@@ -292,11 +467,11 @@ impl ProviderManager {
|
||||
!self.runtime_type_ids.contains(&type_str)
|
||||
});
|
||||
|
||||
// Drop old runtimes (catch panics from runtime cleanup)
|
||||
// Drop old runtimes. Panics here will poison the ProviderManager RwLock,
|
||||
// which is caught and reported by the watcher thread (see plugins/watcher.rs).
|
||||
info!("Dropping old runtimes before reload");
|
||||
let old_runtimes = std::mem::take(&mut self.runtimes);
|
||||
drop(std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
drop(old_runtimes);
|
||||
})));
|
||||
drop(old_runtimes);
|
||||
self.runtime_type_ids.clear();
|
||||
|
||||
let owlry_version = env!("CARGO_PKG_VERSION");
|
||||
@@ -435,6 +610,14 @@ impl ProviderManager {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check built-in dynamic providers
|
||||
for provider in &self.builtin_dynamic {
|
||||
if provider.execute_action(command) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
@@ -506,6 +689,7 @@ impl ProviderManager {
|
||||
query: &str,
|
||||
max_results: usize,
|
||||
filter: &crate::filter::ProviderFilter,
|
||||
tag_filter: Option<&str>,
|
||||
) -> Vec<(LaunchItem, i64)> {
|
||||
// Collect items from core providers
|
||||
let core_items = self
|
||||
@@ -521,16 +705,15 @@ impl ProviderManager {
|
||||
.filter(|p| filter.is_active(p.provider_type()))
|
||||
.flat_map(|p| p.items().iter().cloned());
|
||||
|
||||
let all_items = core_items.chain(native_items).filter(|item| {
|
||||
tag_filter.is_none_or(|t| item.tags.iter().any(|it| it == t))
|
||||
});
|
||||
|
||||
if query.is_empty() {
|
||||
return core_items
|
||||
.chain(native_items)
|
||||
.take(max_results)
|
||||
.map(|item| (item, 0))
|
||||
.collect();
|
||||
return all_items.take(max_results).map(|item| (item, 0)).collect();
|
||||
}
|
||||
|
||||
let mut results: Vec<(LaunchItem, i64)> = core_items
|
||||
.chain(native_items)
|
||||
let mut results: Vec<(LaunchItem, i64)> = all_items
|
||||
.filter_map(|item| {
|
||||
let name_score = self.matcher.fuzzy_match(&item.name, query);
|
||||
let desc_score = item
|
||||
@@ -570,6 +753,7 @@ impl ProviderManager {
|
||||
query, max_results, frecency_weight
|
||||
);
|
||||
|
||||
let now = Utc::now();
|
||||
let mut results: Vec<(LaunchItem, i64)> = Vec::new();
|
||||
|
||||
// Add widget items first (highest priority) - only when:
|
||||
@@ -600,30 +784,59 @@ impl ProviderManager {
|
||||
let dynamic_results = provider.query(query);
|
||||
// Priority comes from plugin-declared priority field
|
||||
let base_score = provider.priority() as i64;
|
||||
|
||||
// Auto-detect plugins (calc, conv) get a grouping bonus so
|
||||
// all their results stay together above generic search results
|
||||
let grouping_bonus: i64 = match provider.provider_type() {
|
||||
ProviderType::Plugin(ref id)
|
||||
if matches!(id.as_str(), "calc" | "conv") =>
|
||||
{
|
||||
10_000
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
for (idx, item) in dynamic_results.into_iter().enumerate() {
|
||||
results.push((item, base_score - idx as i64));
|
||||
results.push((item, base_score + grouping_bonus - idx as i64));
|
||||
}
|
||||
}
|
||||
|
||||
// Built-in dynamic providers (calculator, converter)
|
||||
for provider in &self.builtin_dynamic {
|
||||
if !filter.is_active(provider.provider_type()) {
|
||||
continue;
|
||||
}
|
||||
let dynamic_results = provider.query(query);
|
||||
let base_score = provider.priority() as i64;
|
||||
|
||||
let grouping_bonus: i64 = match provider.provider_type() {
|
||||
ProviderType::Plugin(ref id)
|
||||
if matches!(id.as_str(), "calc" | "conv") =>
|
||||
{
|
||||
10_000
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
for (idx, item) in dynamic_results.into_iter().enumerate() {
|
||||
results.push((item, base_score + grouping_bonus - 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
|
||||
let mut scored_refs: Vec<(&LaunchItem, i64)> = 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)
|
||||
.flat_map(|p| p.items().iter())
|
||||
.chain(
|
||||
self.static_native_providers
|
||||
.iter()
|
||||
.filter(|p| filter.is_active(p.provider_type()))
|
||||
.flat_map(|p| p.items().iter()),
|
||||
)
|
||||
.filter(|item| {
|
||||
// Apply tag filter if present
|
||||
if let Some(tag) = tag_filter {
|
||||
@@ -633,14 +846,21 @@ impl ProviderManager {
|
||||
}
|
||||
})
|
||||
.map(|item| {
|
||||
let frecency_score = frecency.get_score(&item.id);
|
||||
let frecency_score = frecency.get_score_at(&item.id, now);
|
||||
let boosted = (frecency_score * frecency_weight * 100.0) as i64;
|
||||
(item, boosted)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Combine widgets (already in results) with frecency items
|
||||
results.extend(items);
|
||||
// Partial sort: O(n) average to find top max_results, then O(k log k) to order them
|
||||
if scored_refs.len() > max_results {
|
||||
scored_refs.select_nth_unstable_by(max_results, |a, b| b.1.cmp(&a.1));
|
||||
scored_refs.truncate(max_results);
|
||||
}
|
||||
scored_refs.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
|
||||
// Clone only the survivors
|
||||
results.extend(scored_refs.into_iter().map(|(item, score)| (item.clone(), score)));
|
||||
results.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
results.truncate(max_results);
|
||||
return results;
|
||||
@@ -648,7 +868,7 @@ impl ProviderManager {
|
||||
|
||||
// Regular search with frecency boost and tag matching
|
||||
// Helper closure for scoring items
|
||||
let score_item = |item: &LaunchItem| -> Option<(LaunchItem, i64)> {
|
||||
let score_item = |item: &LaunchItem| -> Option<i64> {
|
||||
// Apply tag filter if present
|
||||
if let Some(tag) = tag_filter
|
||||
&& !item.tags.iter().any(|t| t.to_lowercase().contains(tag))
|
||||
@@ -682,35 +902,59 @@ impl ProviderManager {
|
||||
};
|
||||
|
||||
base_score.map(|s| {
|
||||
let frecency_score = frecency.get_score(&item.id);
|
||||
let frecency_score = frecency.get_score_at(&item.id, now);
|
||||
let frecency_boost = (frecency_score * frecency_weight * 10.0) as i64;
|
||||
(item.clone(), s + frecency_boost)
|
||||
|
||||
// Exact name match bonus — apps get a higher boost
|
||||
let exact_match_boost = if item.name.eq_ignore_ascii_case(query) {
|
||||
match &item.provider {
|
||||
ProviderType::Application => 50_000,
|
||||
_ => 30_000,
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
s + frecency_boost + exact_match_boost
|
||||
})
|
||||
};
|
||||
|
||||
// Search core providers
|
||||
// Score static items by reference (no cloning)
|
||||
let mut scored_refs: Vec<(&LaunchItem, i64)> = Vec::new();
|
||||
|
||||
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);
|
||||
if let Some(score) = score_item(item) {
|
||||
scored_refs.push((item, score));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
if let Some(score) = score_item(item) {
|
||||
scored_refs.push((item, score));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Partial sort: O(n) average to find top max_results, then O(k log k) to order them
|
||||
if scored_refs.len() > max_results {
|
||||
scored_refs.select_nth_unstable_by(max_results, |a, b| b.1.cmp(&a.1));
|
||||
scored_refs.truncate(max_results);
|
||||
}
|
||||
scored_refs.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
|
||||
// Clone only the survivors
|
||||
results.extend(scored_refs.into_iter().map(|(item, score)| (item.clone(), score)));
|
||||
|
||||
// Final sort merges dynamic results (already in `results`) with static top-N
|
||||
results.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
results.truncate(max_results);
|
||||
|
||||
@@ -991,6 +1235,7 @@ mod tests {
|
||||
command: format!("run-{}", id),
|
||||
terminal: false,
|
||||
tags: Vec::new(),
|
||||
source: ItemSource::Core,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1076,4 +1321,5 @@ mod tests {
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].0.name, "Firefox");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
//! Native plugins are loaded from `/usr/lib/owlry/plugins/` as `.so` files
|
||||
//! and provide search providers via an ABI-stable interface.
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::sync::Arc;
|
||||
|
||||
use log::debug;
|
||||
use owlry_plugin_api::{
|
||||
PluginItem as ApiPluginItem, ProviderHandle, ProviderInfo, ProviderKind, ProviderPosition,
|
||||
};
|
||||
|
||||
use super::{LaunchItem, Provider, ProviderType};
|
||||
use super::{ItemSource, LaunchItem, Provider, ProviderType};
|
||||
use crate::plugins::native_loader::NativePlugin;
|
||||
|
||||
/// A provider backed by a native plugin
|
||||
@@ -28,7 +28,7 @@ pub struct NativeProvider {
|
||||
/// Handle to the provider state in the plugin
|
||||
handle: ProviderHandle,
|
||||
/// Cached items (for static providers)
|
||||
items: RwLock<Vec<LaunchItem>>,
|
||||
items: Vec<LaunchItem>,
|
||||
}
|
||||
|
||||
impl NativeProvider {
|
||||
@@ -40,7 +40,7 @@ impl NativeProvider {
|
||||
plugin,
|
||||
info,
|
||||
handle,
|
||||
items: RwLock::new(Vec::new()),
|
||||
items: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,21 @@ impl NativeProvider {
|
||||
ProviderType::Plugin(self.info.type_id.to_string())
|
||||
}
|
||||
|
||||
/// The ID of the plugin that owns this provider.
|
||||
pub fn plugin_id(&self) -> &str {
|
||||
self.plugin.id()
|
||||
}
|
||||
|
||||
/// The human-readable name of the plugin that owns this provider.
|
||||
pub fn plugin_name(&self) -> &str {
|
||||
self.plugin.name()
|
||||
}
|
||||
|
||||
/// The version string of the plugin that owns this provider.
|
||||
pub fn plugin_version(&self) -> &str {
|
||||
self.plugin.info.version.as_str()
|
||||
}
|
||||
|
||||
/// Convert a plugin API item to a core LaunchItem
|
||||
fn convert_item(&self, item: ApiPluginItem) -> LaunchItem {
|
||||
LaunchItem {
|
||||
@@ -61,6 +76,7 @@ impl NativeProvider {
|
||||
command: item.command.to_string(),
|
||||
terminal: item.terminal,
|
||||
tags: item.keywords.iter().map(|s| s.to_string()).collect(),
|
||||
source: ItemSource::NativePlugin,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +90,7 @@ impl NativeProvider {
|
||||
let is_special_query = query.starts_with("?SUBMENU:") || query.starts_with("!");
|
||||
|
||||
if self.info.provider_type != ProviderKind::Dynamic && !is_special_query {
|
||||
return self.items.read().unwrap().clone();
|
||||
return self.items.clone();
|
||||
}
|
||||
|
||||
let api_items = self.plugin.query_provider(self.handle, query);
|
||||
@@ -171,22 +187,11 @@ impl Provider for NativeProvider {
|
||||
items.len()
|
||||
);
|
||||
|
||||
*self.items.write().unwrap() = items;
|
||||
self.items = items;
|
||||
}
|
||||
|
||||
fn items(&self) -> &[LaunchItem] {
|
||||
// This is tricky with RwLock - we need to return a reference but can't
|
||||
// hold the lock across the return. We use a raw pointer approach.
|
||||
//
|
||||
// SAFETY: The items Vec is only modified during refresh() which takes
|
||||
// &mut self, so no concurrent modification can occur while this
|
||||
// reference is live.
|
||||
unsafe {
|
||||
let guard = self.items.read().unwrap();
|
||||
let ptr = guard.as_ptr();
|
||||
let len = guard.len();
|
||||
std::slice::from_raw_parts(ptr, len)
|
||||
}
|
||||
&self.items
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
149
crates/owlry-core/src/providers/system.rs
Normal file
149
crates/owlry-core/src/providers/system.rs
Normal file
@@ -0,0 +1,149 @@
|
||||
use super::{ItemSource, LaunchItem, Provider, ProviderType};
|
||||
|
||||
/// Built-in system provider. Returns a fixed set of power and session management actions.
|
||||
///
|
||||
/// This is a static provider — items are populated in `new()` and `refresh()` is a no-op.
|
||||
pub(crate) struct SystemProvider {
|
||||
items: Vec<LaunchItem>,
|
||||
}
|
||||
|
||||
impl SystemProvider {
|
||||
pub fn new() -> Self {
|
||||
let commands: &[(&str, &str, &str, &str, &str)] = &[
|
||||
(
|
||||
"shutdown",
|
||||
"Shutdown",
|
||||
"Power off the system",
|
||||
"system-shutdown",
|
||||
"systemctl poweroff",
|
||||
),
|
||||
(
|
||||
"reboot",
|
||||
"Reboot",
|
||||
"Restart the system",
|
||||
"system-reboot",
|
||||
"systemctl reboot",
|
||||
),
|
||||
(
|
||||
"reboot-bios",
|
||||
"Reboot to BIOS",
|
||||
"Restart into UEFI/BIOS setup",
|
||||
"system-reboot",
|
||||
"systemctl reboot --firmware-setup",
|
||||
),
|
||||
(
|
||||
"suspend",
|
||||
"Suspend",
|
||||
"Suspend to RAM",
|
||||
"system-suspend",
|
||||
"systemctl suspend",
|
||||
),
|
||||
(
|
||||
"hibernate",
|
||||
"Hibernate",
|
||||
"Suspend to disk",
|
||||
"system-suspend-hibernate",
|
||||
"systemctl hibernate",
|
||||
),
|
||||
(
|
||||
"lock",
|
||||
"Lock Screen",
|
||||
"Lock the session",
|
||||
"system-lock-screen",
|
||||
"loginctl lock-session",
|
||||
),
|
||||
(
|
||||
"logout",
|
||||
"Log Out",
|
||||
"End the current session",
|
||||
"system-log-out",
|
||||
"loginctl terminate-session self",
|
||||
),
|
||||
];
|
||||
|
||||
let items = commands
|
||||
.iter()
|
||||
.map(|(action_id, name, description, icon, command)| LaunchItem {
|
||||
id: format!("sys:{}", action_id),
|
||||
name: name.to_string(),
|
||||
description: Some(description.to_string()),
|
||||
icon: Some(icon.to_string()),
|
||||
provider: ProviderType::Plugin("sys".into()),
|
||||
command: command.to_string(),
|
||||
terminal: false,
|
||||
tags: vec!["system".into()],
|
||||
source: ItemSource::Core,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self { items }
|
||||
}
|
||||
}
|
||||
|
||||
impl Provider for SystemProvider {
|
||||
fn name(&self) -> &str {
|
||||
"System"
|
||||
}
|
||||
|
||||
fn provider_type(&self) -> ProviderType {
|
||||
ProviderType::Plugin("sys".into())
|
||||
}
|
||||
|
||||
fn refresh(&mut self) {
|
||||
// Static provider — no-op
|
||||
}
|
||||
|
||||
fn items(&self) -> &[LaunchItem] {
|
||||
&self.items
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn has_seven_actions() {
|
||||
let provider = SystemProvider::new();
|
||||
assert_eq!(provider.items().len(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contains_expected_action_names() {
|
||||
let provider = SystemProvider::new();
|
||||
let names: Vec<&str> = provider.items().iter().map(|i| i.name.as_str()).collect();
|
||||
assert!(names.contains(&"Shutdown"));
|
||||
assert!(names.contains(&"Reboot"));
|
||||
assert!(names.contains(&"Lock Screen"));
|
||||
assert!(names.contains(&"Log Out"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_type_is_sys_plugin() {
|
||||
let provider = SystemProvider::new();
|
||||
assert_eq!(provider.provider_type(), ProviderType::Plugin("sys".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shutdown_command_is_correct() {
|
||||
let provider = SystemProvider::new();
|
||||
let shutdown = provider
|
||||
.items()
|
||||
.iter()
|
||||
.find(|i| i.name == "Shutdown")
|
||||
.expect("Shutdown item must exist");
|
||||
assert_eq!(shutdown.command, "systemctl poweroff");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_items_have_system_tag() {
|
||||
let provider = SystemProvider::new();
|
||||
for item in provider.items() {
|
||||
assert!(
|
||||
item.tags.contains(&"system".to_string()),
|
||||
"item '{}' is missing 'system' tag",
|
||||
item.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ use std::io::{self, BufRead, BufReader, Write};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
use std::thread;
|
||||
@@ -9,6 +10,73 @@ use std::thread;
|
||||
/// Maximum allowed size for a single IPC request line (1 MiB).
|
||||
const MAX_REQUEST_SIZE: usize = 1_048_576;
|
||||
|
||||
/// Maximum number of concurrently active client connections.
|
||||
const MAX_CONNECTIONS: usize = 16;
|
||||
|
||||
/// Tracks active connection count across all handler threads.
|
||||
static ACTIVE_CONNECTIONS: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
/// RAII guard that increments the connection counter on creation and decrements on drop.
|
||||
struct ConnectionGuard;
|
||||
|
||||
impl ConnectionGuard {
|
||||
/// Try to acquire a connection slot. Returns `None` if at capacity.
|
||||
fn try_acquire() -> Option<Self> {
|
||||
let prev = ACTIVE_CONNECTIONS.fetch_add(1, Ordering::SeqCst);
|
||||
if prev >= MAX_CONNECTIONS {
|
||||
ACTIVE_CONNECTIONS.fetch_sub(1, Ordering::SeqCst);
|
||||
None
|
||||
} else {
|
||||
Some(ConnectionGuard)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ConnectionGuard {
|
||||
fn drop(&mut self) {
|
||||
ACTIVE_CONNECTIONS.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a newline-terminated line from `reader` without allocating beyond `max` bytes.
|
||||
///
|
||||
/// Unlike `BufRead::read_line`, this checks the size limit incrementally against
|
||||
/// the internal buffer rather than after the full allocation. Returns `Ok(None)`
|
||||
/// on clean EOF, `Err(InvalidData)` when `max` is exceeded before finding `\n`.
|
||||
fn read_bounded_line(reader: &mut BufReader<UnixStream>, max: usize) -> io::Result<Option<String>> {
|
||||
let mut buf: Vec<u8> = Vec::with_capacity(4096);
|
||||
loop {
|
||||
let available = reader.fill_buf()?;
|
||||
if available.is_empty() {
|
||||
return if buf.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(String::from_utf8_lossy(&buf).into_owned()))
|
||||
};
|
||||
}
|
||||
if let Some(pos) = available.iter().position(|&b| b == b'\n') {
|
||||
if buf.len() + pos > max {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("request too large (exceeded {} bytes)", max),
|
||||
));
|
||||
}
|
||||
buf.extend_from_slice(&available[..pos]);
|
||||
reader.consume(pos + 1);
|
||||
return Ok(Some(String::from_utf8_lossy(&buf).into_owned()));
|
||||
}
|
||||
let len = available.len();
|
||||
if buf.len() + len > max {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("request too large (exceeded {} bytes)", max),
|
||||
));
|
||||
}
|
||||
buf.extend_from_slice(available);
|
||||
reader.consume(len);
|
||||
}
|
||||
}
|
||||
|
||||
use log::{error, info, warn};
|
||||
|
||||
use crate::config::Config;
|
||||
@@ -24,7 +92,7 @@ pub struct Server {
|
||||
socket_path: PathBuf,
|
||||
provider_manager: Arc<RwLock<ProviderManager>>,
|
||||
frecency: Arc<RwLock<FrecencyStore>>,
|
||||
config: Arc<Config>,
|
||||
config: Arc<RwLock<Config>>,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
@@ -42,8 +110,10 @@ impl Server {
|
||||
std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(0o600))?;
|
||||
info!("IPC server listening on {:?}", socket_path);
|
||||
|
||||
let config = Config::load_or_default();
|
||||
let provider_manager = ProviderManager::new_with_config(&config);
|
||||
let config = Arc::new(RwLock::new(Config::load_or_default()));
|
||||
// Share config with native plugin loader so plugins can read their own config sections.
|
||||
crate::plugins::native_loader::set_shared_config(Arc::clone(&config));
|
||||
let provider_manager = ProviderManager::new_with_config(Arc::clone(&config));
|
||||
let frecency = FrecencyStore::new();
|
||||
|
||||
Ok(Self {
|
||||
@@ -51,7 +121,7 @@ impl Server {
|
||||
socket_path: socket_path.to_path_buf(),
|
||||
provider_manager: Arc::new(RwLock::new(provider_manager)),
|
||||
frecency: Arc::new(RwLock::new(frecency)),
|
||||
config: Arc::new(config),
|
||||
config,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -60,18 +130,106 @@ impl Server {
|
||||
// Start filesystem watcher for user plugin hot-reload
|
||||
crate::plugins::watcher::start_watching(Arc::clone(&self.provider_manager));
|
||||
|
||||
// SIGHUP handler: reload config from disk into the shared Arc<RwLock<Config>>.
|
||||
{
|
||||
use signal_hook::consts::SIGHUP;
|
||||
use signal_hook::iterator::Signals;
|
||||
let config = Arc::clone(&self.config);
|
||||
let mut signals = Signals::new([SIGHUP])
|
||||
.map_err(io::Error::other)?;
|
||||
thread::spawn(move || {
|
||||
for _sig in signals.forever() {
|
||||
match Config::load() {
|
||||
Ok(new_cfg) => {
|
||||
match config.write() {
|
||||
Ok(mut cfg) => {
|
||||
*cfg = new_cfg;
|
||||
info!("Config reloaded via SIGHUP");
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("SIGHUP: config lock poisoned; reload skipped");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("SIGHUP: failed to reload config: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// SIGTERM/SIGINT handler: save frecency before exiting.
|
||||
// Replaces the ctrlc handler in main.rs so all signal management lives here.
|
||||
{
|
||||
use signal_hook::consts::{SIGINT, SIGTERM};
|
||||
use signal_hook::iterator::Signals;
|
||||
let frecency = Arc::clone(&self.frecency);
|
||||
let socket_path = self.socket_path.clone();
|
||||
let mut signals = Signals::new([SIGTERM, SIGINT])
|
||||
.map_err(io::Error::other)?;
|
||||
thread::spawn(move || {
|
||||
// Block until we receive SIGTERM or SIGINT, then save and exit.
|
||||
let _ = signals.forever().next();
|
||||
match frecency.write() {
|
||||
Ok(mut f) => {
|
||||
if let Err(e) = f.save() {
|
||||
warn!("Shutdown: frecency save failed: {}", e);
|
||||
} else {
|
||||
info!("Shutdown: frecency saved");
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("Shutdown: frecency lock poisoned; skipping save");
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_file(&socket_path);
|
||||
std::process::exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
// Periodic frecency auto-save every 5 minutes.
|
||||
{
|
||||
let frecency = Arc::clone(&self.frecency);
|
||||
thread::spawn(move || loop {
|
||||
thread::sleep(Duration::from_secs(300));
|
||||
match frecency.write() {
|
||||
Ok(mut f) => {
|
||||
if let Err(e) = f.save() {
|
||||
warn!("Periodic frecency save failed: {}", e);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("Periodic frecency save: lock poisoned; skipping");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
Ok(mut stream) => {
|
||||
match ConnectionGuard::try_acquire() {
|
||||
Some(guard) => {
|
||||
let pm = Arc::clone(&self.provider_manager);
|
||||
let frecency = Arc::clone(&self.frecency);
|
||||
let config = Arc::clone(&self.config);
|
||||
thread::spawn(move || {
|
||||
let _guard = guard; // released on thread exit
|
||||
if let Err(e) = Self::handle_client(stream, pm, frecency, config) {
|
||||
warn!("Client handler error: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
None => {
|
||||
warn!("Connection limit reached ({} max); rejecting client", MAX_CONNECTIONS);
|
||||
let resp = Response::Error {
|
||||
message: format!("server busy: max {} concurrent connections", MAX_CONNECTIONS),
|
||||
};
|
||||
let _ = write_response(&mut stream, &resp);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to accept connection: {}", e);
|
||||
@@ -101,30 +259,25 @@ impl Server {
|
||||
stream: UnixStream,
|
||||
pm: Arc<RwLock<ProviderManager>>,
|
||||
frecency: Arc<RwLock<FrecencyStore>>,
|
||||
config: Arc<Config>,
|
||||
config: Arc<RwLock<Config>>,
|
||||
) -> io::Result<()> {
|
||||
stream.set_read_timeout(Some(Duration::from_secs(30)))?;
|
||||
let mut reader = BufReader::new(stream.try_clone()?);
|
||||
let mut writer = stream;
|
||||
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let bytes_read = reader.read_line(&mut line)?;
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
if line.len() > MAX_REQUEST_SIZE {
|
||||
let resp = Response::Error {
|
||||
message: format!(
|
||||
"request too large ({} bytes, max {})",
|
||||
line.len(),
|
||||
MAX_REQUEST_SIZE
|
||||
),
|
||||
};
|
||||
write_response(&mut writer, &resp)?;
|
||||
break;
|
||||
}
|
||||
let line = match read_bounded_line(&mut reader, MAX_REQUEST_SIZE) {
|
||||
Ok(Some(l)) => l,
|
||||
Ok(None) => break,
|
||||
Err(e) if e.kind() == io::ErrorKind::InvalidData => {
|
||||
let resp = Response::Error {
|
||||
message: format!("request too large (max {} bytes)", MAX_REQUEST_SIZE),
|
||||
};
|
||||
write_response(&mut writer, &resp)?;
|
||||
break;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -156,7 +309,7 @@ impl Server {
|
||||
request: &Request,
|
||||
pm: &Arc<RwLock<ProviderManager>>,
|
||||
frecency: &Arc<RwLock<FrecencyStore>>,
|
||||
config: &Arc<Config>,
|
||||
config: &Arc<RwLock<Config>>,
|
||||
) -> Response {
|
||||
match request {
|
||||
Request::Query { text, modes } => {
|
||||
@@ -164,11 +317,22 @@ impl Server {
|
||||
Some(m) => ProviderFilter::from_mode_strings(m),
|
||||
None => ProviderFilter::all(),
|
||||
};
|
||||
let max = config.general.max_results;
|
||||
let weight = config.providers.frecency_weight;
|
||||
let (max, weight) = {
|
||||
let cfg = match config.read() {
|
||||
Ok(g) => g,
|
||||
Err(_) => return Response::Error { message: "internal error: config lock poisoned".into() },
|
||||
};
|
||||
(cfg.general.max_results, cfg.providers.frecency_weight)
|
||||
};
|
||||
|
||||
let pm_guard = pm.read().unwrap_or_else(|e| e.into_inner());
|
||||
let frecency_guard = frecency.read().unwrap_or_else(|e| e.into_inner());
|
||||
let pm_guard = match pm.read() {
|
||||
Ok(g) => g,
|
||||
Err(_) => return Response::Error { message: "internal error: provider lock poisoned".into() },
|
||||
};
|
||||
let frecency_guard = match frecency.read() {
|
||||
Ok(g) => g,
|
||||
Err(_) => return Response::Error { message: "internal error: frecency lock poisoned".into() },
|
||||
};
|
||||
let results = pm_guard.search_with_frecency(
|
||||
text,
|
||||
max,
|
||||
@@ -190,13 +354,19 @@ impl Server {
|
||||
item_id,
|
||||
provider: _,
|
||||
} => {
|
||||
let mut frecency_guard = frecency.write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut frecency_guard = match frecency.write() {
|
||||
Ok(g) => g,
|
||||
Err(_) => return Response::Error { message: "internal error: frecency lock poisoned".into() },
|
||||
};
|
||||
frecency_guard.record_launch(item_id);
|
||||
Response::Ack
|
||||
}
|
||||
|
||||
Request::Providers => {
|
||||
let pm_guard = pm.read().unwrap_or_else(|e| e.into_inner());
|
||||
let pm_guard = match pm.read() {
|
||||
Ok(g) => g,
|
||||
Err(_) => return Response::Error { message: "internal error: provider lock poisoned".into() },
|
||||
};
|
||||
let descs = pm_guard.available_providers();
|
||||
Response::Providers {
|
||||
list: descs.into_iter().map(descriptor_to_desc).collect(),
|
||||
@@ -204,7 +374,10 @@ impl Server {
|
||||
}
|
||||
|
||||
Request::Refresh { provider } => {
|
||||
let mut pm_guard = pm.write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut pm_guard = match pm.write() {
|
||||
Ok(g) => g,
|
||||
Err(_) => return Response::Error { message: "internal error: provider lock poisoned".into() },
|
||||
};
|
||||
pm_guard.refresh_provider(provider);
|
||||
Response::Ack
|
||||
}
|
||||
@@ -215,7 +388,10 @@ impl Server {
|
||||
}
|
||||
|
||||
Request::Submenu { plugin_id, data } => {
|
||||
let pm_guard = pm.read().unwrap_or_else(|e| e.into_inner());
|
||||
let pm_guard = match pm.read() {
|
||||
Ok(g) => g,
|
||||
Err(_) => return Response::Error { message: "internal error: provider lock poisoned".into() },
|
||||
};
|
||||
match pm_guard.query_submenu_actions(plugin_id, data, plugin_id) {
|
||||
Some((_name, actions)) => Response::SubmenuItems {
|
||||
items: actions
|
||||
@@ -230,7 +406,10 @@ impl Server {
|
||||
}
|
||||
|
||||
Request::PluginAction { command } => {
|
||||
let pm_guard = pm.read().unwrap_or_else(|e| e.into_inner());
|
||||
let pm_guard = match pm.read() {
|
||||
Ok(g) => g,
|
||||
Err(_) => return Response::Error { message: "internal error: provider lock poisoned".into() },
|
||||
};
|
||||
if pm_guard.execute_plugin_action(command) {
|
||||
Response::Ack
|
||||
} else {
|
||||
@@ -239,6 +418,16 @@ impl Server {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Request::PluginList => {
|
||||
let pm_guard = match pm.read() {
|
||||
Ok(g) => g,
|
||||
Err(_) => return Response::Error { message: "internal error: provider lock poisoned".into() },
|
||||
};
|
||||
Response::PluginList {
|
||||
entries: pm_guard.plugin_registry.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -272,6 +461,7 @@ fn launch_item_to_result(item: LaunchItem, score: i64) -> ResultItem {
|
||||
command: Some(item.command),
|
||||
terminal: item.terminal,
|
||||
tags: item.tags,
|
||||
source: item.source.as_str().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,3 +474,57 @@ fn descriptor_to_desc(desc: crate::providers::ProviderDescriptor) -> ProviderDes
|
||||
position: desc.position,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Wrap a Cursor in a BufReader backed by a UnixStream-like interface.
|
||||
// Since read_bounded_line takes BufReader<UnixStream>, we test it indirectly
|
||||
// through an in-memory byte slice via a helper.
|
||||
fn bounded_line_from_bytes(data: &[u8], max: usize) -> io::Result<Option<String>> {
|
||||
// Use a pipe to simulate UnixStream I/O.
|
||||
use std::os::unix::net::UnixStream;
|
||||
let (mut write_end, read_end) = UnixStream::pair()?;
|
||||
write_end.write_all(data)?;
|
||||
drop(write_end); // Signal EOF to reader
|
||||
let mut reader = BufReader::new(read_end);
|
||||
read_bounded_line(&mut reader, max)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_line_within_limit() {
|
||||
let result = bounded_line_from_bytes(b"hello world\n", 100).unwrap();
|
||||
assert_eq!(result, Some("hello world".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_at_exactly_max_succeeds() {
|
||||
// "aaa...a\n" where content is exactly max bytes
|
||||
let mut data = vec![b'a'; 100];
|
||||
data.push(b'\n');
|
||||
let result = bounded_line_from_bytes(&data, 100).unwrap();
|
||||
assert_eq!(result, Some("a".repeat(100)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_exceeding_max_errors() {
|
||||
let mut data = vec![b'a'; 101];
|
||||
data.push(b'\n');
|
||||
let result = bounded_line_from_bytes(&data, 100);
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidData);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_returns_none() {
|
||||
let result = bounded_line_from_bytes(b"", 100).unwrap();
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_trailing_newline_returns_content() {
|
||||
let result = bounded_line_from_bytes(b"hello", 100).unwrap();
|
||||
assert_eq!(result, Some("hello".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ fn test_results_response_roundtrip() {
|
||||
command: Some("firefox".into()),
|
||||
terminal: false,
|
||||
tags: vec![],
|
||||
source: "core".into(),
|
||||
}],
|
||||
};
|
||||
let json = serde_json::to_string(&resp).unwrap();
|
||||
@@ -140,6 +141,7 @@ fn test_terminal_field_roundtrip() {
|
||||
command: Some("htop".into()),
|
||||
terminal: true,
|
||||
tags: vec![],
|
||||
source: "cmd".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&item).unwrap();
|
||||
assert!(json.contains("\"terminal\":true"));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "owlry-lua"
|
||||
version = "1.1.0"
|
||||
version = "1.1.3"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
@@ -30,8 +30,11 @@ serde_json = "1.0"
|
||||
# Version compatibility
|
||||
semver = "1"
|
||||
|
||||
# Logging
|
||||
log = "0.4"
|
||||
|
||||
# HTTP client for plugins
|
||||
reqwest = { version = "0.13", features = ["blocking", "json"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["native-tls", "blocking", "json"] }
|
||||
|
||||
# Math expression evaluation
|
||||
meval = "0.2"
|
||||
|
||||
@@ -50,3 +50,8 @@ pub fn call_refresh(lua: &Lua, provider_name: &str) -> LuaResult<Vec<PluginItem>
|
||||
pub fn call_query(lua: &Lua, provider_name: &str, query: &str) -> LuaResult<Vec<PluginItem>> {
|
||||
provider::call_query(lua, provider_name, query)
|
||||
}
|
||||
|
||||
/// Call the global `refresh()` function (for manifest-declared providers)
|
||||
pub fn call_global_refresh(lua: &Lua) -> LuaResult<Vec<PluginItem>> {
|
||||
provider::call_global_refresh(lua)
|
||||
}
|
||||
|
||||
@@ -76,6 +76,15 @@ fn register_provider(lua: &Lua, config: Table) -> LuaResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Call the top-level `refresh()` global function (for manifest-declared providers)
|
||||
pub fn call_global_refresh(lua: &Lua) -> LuaResult<Vec<PluginItem>> {
|
||||
let globals = lua.globals();
|
||||
match globals.get::<Function>("refresh") {
|
||||
Ok(refresh_fn) => parse_items_result(refresh_fn.call(())?),
|
||||
Err(_) => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all registered providers
|
||||
pub fn get_registrations(lua: &Lua) -> LuaResult<Vec<ProviderRegistration>> {
|
||||
// Suppress unused warning
|
||||
|
||||
@@ -68,8 +68,11 @@ pub struct RuntimeHandle {
|
||||
pub ptr: *mut (),
|
||||
}
|
||||
|
||||
// SAFETY: LuaRuntimeState (pointed to by RuntimeHandle) contains mlua::Lua, which is
|
||||
// Send when the "send" feature is enabled (enabled in Cargo.toml). RuntimeHandle itself
|
||||
// is Copy and has no interior mutability — Sync is NOT implemented because concurrent
|
||||
// access is serialized by Arc<Mutex<RuntimeHandle>> in the runtime loader.
|
||||
unsafe impl Send for RuntimeHandle {}
|
||||
unsafe impl Sync for RuntimeHandle {}
|
||||
|
||||
impl RuntimeHandle {
|
||||
/// Create a null handle (reserved for error cases)
|
||||
|
||||
@@ -96,8 +96,28 @@ impl LoadedPlugin {
|
||||
.as_ref()
|
||||
.ok_or_else(|| "Plugin not initialized".to_string())?;
|
||||
|
||||
api::get_provider_registrations(lua)
|
||||
.map_err(|e| format!("Failed to get registrations: {}", e))
|
||||
let mut regs = api::get_provider_registrations(lua)
|
||||
.map_err(|e| format!("Failed to get registrations: {}", e))?;
|
||||
|
||||
// Fall back to manifest [[providers]] declarations when the script
|
||||
// doesn't call owlry.provider.register() (new-style plugins)
|
||||
if regs.is_empty() {
|
||||
for decl in &self.manifest.providers {
|
||||
regs.push(ProviderRegistration {
|
||||
name: decl.id.clone(),
|
||||
display_name: decl.name.clone(),
|
||||
type_id: decl.type_id.clone().unwrap_or_else(|| decl.id.clone()),
|
||||
default_icon: decl
|
||||
.icon
|
||||
.clone()
|
||||
.unwrap_or_else(|| "application-x-addon".to_string()),
|
||||
prefix: decl.prefix.clone(),
|
||||
is_dynamic: decl.provider_type == "dynamic",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(regs)
|
||||
}
|
||||
|
||||
/// Call a provider's refresh function
|
||||
@@ -107,7 +127,17 @@ impl LoadedPlugin {
|
||||
.as_ref()
|
||||
.ok_or_else(|| "Plugin not initialized".to_string())?;
|
||||
|
||||
api::call_refresh(lua, provider_name).map_err(|e| format!("Refresh failed: {}", e))
|
||||
let items = api::call_refresh(lua, provider_name)
|
||||
.map_err(|e| format!("Refresh failed: {}", e))?;
|
||||
|
||||
// If the API path returned nothing, try calling the global refresh()
|
||||
// function directly (new-style plugins with manifest [[providers]])
|
||||
if items.is_empty() {
|
||||
return api::call_global_refresh(lua)
|
||||
.map_err(|e| format!("Refresh failed: {}", e));
|
||||
}
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// Call a provider's query function
|
||||
@@ -156,9 +186,18 @@ pub fn discover_plugins(
|
||||
|
||||
match PluginManifest::load(&manifest_path) {
|
||||
Ok(manifest) => {
|
||||
// Skip plugins whose entry point is not a Lua file
|
||||
if !manifest.plugin.entry.ends_with(".lua") {
|
||||
log::debug!(
|
||||
"owlry-lua: Skipping non-Lua plugin at {} (entry: {})",
|
||||
path.display(),
|
||||
manifest.plugin.entry
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let id = manifest.plugin.id.clone();
|
||||
if plugins.contains_key(&id) {
|
||||
eprintln!(
|
||||
log::warn!(
|
||||
"owlry-lua: Duplicate plugin ID '{}', skipping {}",
|
||||
id,
|
||||
path.display()
|
||||
@@ -168,7 +207,7 @@ pub fn discover_plugins(
|
||||
plugins.insert(id, (manifest, path));
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
log::warn!(
|
||||
"owlry-lua: Failed to load plugin at {}: {}",
|
||||
path.display(),
|
||||
e
|
||||
@@ -229,4 +268,79 @@ version = "1.0.0"
|
||||
let plugins = discover_plugins(Path::new("/nonexistent/path")).unwrap();
|
||||
assert!(plugins.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discover_skips_non_lua_plugins() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let plugins_dir = temp.path();
|
||||
|
||||
// Rune plugin — should be skipped by the Lua runtime
|
||||
let rune_dir = plugins_dir.join("rune-plugin");
|
||||
fs::create_dir_all(&rune_dir).unwrap();
|
||||
fs::write(
|
||||
rune_dir.join("plugin.toml"),
|
||||
r#"
|
||||
[plugin]
|
||||
id = "rune-plugin"
|
||||
name = "Rune Plugin"
|
||||
version = "1.0.0"
|
||||
entry_point = "main.rn"
|
||||
|
||||
[[providers]]
|
||||
id = "rune-plugin"
|
||||
name = "Rune Plugin"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(rune_dir.join("main.rn"), "pub fn refresh() { [] }").unwrap();
|
||||
|
||||
// Lua plugin — should be discovered
|
||||
create_test_plugin(plugins_dir, "lua-plugin");
|
||||
|
||||
let plugins = discover_plugins(plugins_dir).unwrap();
|
||||
assert_eq!(plugins.len(), 1);
|
||||
assert!(plugins.contains_key("lua-plugin"));
|
||||
assert!(!plugins.contains_key("rune-plugin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_manifest_provider_fallback() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let plugin_dir = temp.path().join("test-plugin");
|
||||
fs::create_dir_all(&plugin_dir).unwrap();
|
||||
|
||||
fs::write(
|
||||
plugin_dir.join("plugin.toml"),
|
||||
r#"
|
||||
[plugin]
|
||||
id = "test-plugin"
|
||||
name = "Test Plugin"
|
||||
version = "1.0.0"
|
||||
entry_point = "main.lua"
|
||||
|
||||
[[providers]]
|
||||
id = "test-plugin"
|
||||
name = "Test Plugin"
|
||||
type = "static"
|
||||
type_id = "testplugin"
|
||||
icon = "system-run"
|
||||
prefix = ":tp"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// Script that does NOT call owlry.provider.register()
|
||||
fs::write(plugin_dir.join("main.lua"), "function refresh() return {} end").unwrap();
|
||||
|
||||
let manifest =
|
||||
crate::manifest::PluginManifest::load(&plugin_dir.join("plugin.toml")).unwrap();
|
||||
let mut plugin = LoadedPlugin::new(manifest, plugin_dir);
|
||||
plugin.initialize().unwrap();
|
||||
|
||||
let regs = plugin.get_provider_registrations().unwrap();
|
||||
assert_eq!(regs.len(), 1, "should fall back to [[providers]] declaration");
|
||||
assert_eq!(regs[0].name, "test-plugin");
|
||||
assert_eq!(regs[0].type_id, "testplugin");
|
||||
assert_eq!(regs[0].prefix.as_deref(), Some(":tp"));
|
||||
assert!(!regs[0].is_dynamic);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ use std::path::Path;
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginManifest {
|
||||
pub plugin: PluginInfo,
|
||||
/// Provider declarations from [[providers]] sections (new-style)
|
||||
#[serde(default)]
|
||||
pub providers: Vec<ProviderDecl>,
|
||||
/// Legacy provides block (old-style)
|
||||
#[serde(default)]
|
||||
pub provides: PluginProvides,
|
||||
#[serde(default)]
|
||||
@@ -16,6 +20,26 @@ pub struct PluginManifest {
|
||||
pub settings: HashMap<String, toml::Value>,
|
||||
}
|
||||
|
||||
/// A provider declared in a [[providers]] section
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderDecl {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub prefix: Option<String>,
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
/// "static" (default) or "dynamic"
|
||||
#[serde(default = "default_provider_type", rename = "type")]
|
||||
pub provider_type: String,
|
||||
#[serde(default)]
|
||||
pub type_id: Option<String>,
|
||||
}
|
||||
|
||||
fn default_provider_type() -> String {
|
||||
"static".to_string()
|
||||
}
|
||||
|
||||
/// Core plugin information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginInfo {
|
||||
@@ -127,6 +151,11 @@ impl PluginManifest {
|
||||
));
|
||||
}
|
||||
|
||||
// Lua plugins must have a .lua entry point
|
||||
if !self.plugin.entry.ends_with(".lua") {
|
||||
return Err("Entry point must be a .lua file for Lua plugins".to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,8 @@ pub use abi_stable::std_types::{ROption, RStr, RString, RVec};
|
||||
/// Current plugin API version - plugins must match this
|
||||
/// v2: Added ProviderPosition for widget support
|
||||
/// v3: Added priority field for plugin-declared result ordering
|
||||
pub const API_VERSION: u32 = 3;
|
||||
/// v4: Added get_config_string/int/bool to HostAPI for plugin config access
|
||||
pub const API_VERSION: u32 = 4;
|
||||
|
||||
/// Plugin metadata returned by the info function
|
||||
#[repr(C)]
|
||||
@@ -295,6 +296,18 @@ pub struct HostAPI {
|
||||
|
||||
/// Log a message at error level
|
||||
pub log_error: extern "C" fn(message: RStr<'_>),
|
||||
|
||||
/// Read a string value from this plugin's config section.
|
||||
/// Parameters: plugin_id (the calling plugin's ID), key
|
||||
/// Returns RSome(value) if set, RNone otherwise.
|
||||
pub get_config_string:
|
||||
extern "C" fn(plugin_id: RStr<'_>, key: RStr<'_>) -> ROption<RString>,
|
||||
|
||||
/// Read an integer value from this plugin's config section.
|
||||
pub get_config_int: extern "C" fn(plugin_id: RStr<'_>, key: RStr<'_>) -> ROption<i64>,
|
||||
|
||||
/// Read a boolean value from this plugin's config section.
|
||||
pub get_config_bool: extern "C" fn(plugin_id: RStr<'_>, key: RStr<'_>) -> ROption<bool>,
|
||||
}
|
||||
|
||||
use std::sync::OnceLock;
|
||||
@@ -378,6 +391,30 @@ pub fn log_error(message: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a string value from this plugin's config section (convenience wrapper).
|
||||
/// `plugin_id` must match the ID the plugin declares in its `PluginInfo`.
|
||||
pub fn get_config_string(plugin_id: &str, key: &str) -> Option<String> {
|
||||
host_api().and_then(|api| {
|
||||
(api.get_config_string)(RStr::from_str(plugin_id), RStr::from_str(key))
|
||||
.into_option()
|
||||
.map(|s| s.into_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// Read an integer value from this plugin's config section (convenience wrapper).
|
||||
pub fn get_config_int(plugin_id: &str, key: &str) -> Option<i64> {
|
||||
host_api().and_then(|api| {
|
||||
(api.get_config_int)(RStr::from_str(plugin_id), RStr::from_str(key)).into_option()
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a boolean value from this plugin's config section (convenience wrapper).
|
||||
pub fn get_config_bool(plugin_id: &str, key: &str) -> Option<bool> {
|
||||
host_api().and_then(|api| {
|
||||
(api.get_config_bool)(RStr::from_str(plugin_id), RStr::from_str(key)).into_option()
|
||||
})
|
||||
}
|
||||
|
||||
/// Helper macro for defining plugin vtables
|
||||
///
|
||||
/// Usage:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "owlry-rune"
|
||||
version = "1.1.0"
|
||||
version = "1.1.4"
|
||||
edition = "2024"
|
||||
rust-version = "1.90"
|
||||
description = "Rune scripting runtime for owlry plugins"
|
||||
@@ -15,14 +15,13 @@ owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# Rune scripting language
|
||||
rune = "0.14"
|
||||
rune-modules = { version = "0.14", features = ["full"] }
|
||||
|
||||
# Logging
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
|
||||
# HTTP client for network API
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "blocking"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["native-tls", "json", "blocking"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -203,6 +203,7 @@ pub fn discover_rune_plugins(plugins_dir: &Path) -> Result<HashMap<String, Loade
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
@@ -211,4 +212,81 @@ mod tests {
|
||||
let plugins = discover_rune_plugins(temp.path()).unwrap();
|
||||
assert!(plugins.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discover_skips_non_rune_plugins() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let plugins_dir = temp.path();
|
||||
|
||||
// Lua plugin — should be skipped by the Rune runtime
|
||||
let lua_dir = plugins_dir.join("lua-plugin");
|
||||
fs::create_dir_all(&lua_dir).unwrap();
|
||||
fs::write(
|
||||
lua_dir.join("plugin.toml"),
|
||||
r#"
|
||||
[plugin]
|
||||
id = "lua-plugin"
|
||||
name = "Lua Plugin"
|
||||
version = "1.0.0"
|
||||
entry_point = "main.lua"
|
||||
|
||||
[[providers]]
|
||||
id = "lua-plugin"
|
||||
name = "Lua Plugin"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(lua_dir.join("main.lua"), "function refresh() return {} end").unwrap();
|
||||
|
||||
let plugins = discover_rune_plugins(plugins_dir).unwrap();
|
||||
assert!(plugins.is_empty(), "Lua plugin should be skipped by Rune runtime");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_manifest_provider_fallback() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let plugin_dir = temp.path().join("test-plugin");
|
||||
fs::create_dir_all(&plugin_dir).unwrap();
|
||||
|
||||
fs::write(
|
||||
plugin_dir.join("plugin.toml"),
|
||||
r#"
|
||||
[plugin]
|
||||
id = "test-plugin"
|
||||
name = "Test Plugin"
|
||||
version = "1.0.0"
|
||||
entry_point = "main.rn"
|
||||
|
||||
[[providers]]
|
||||
id = "test-plugin"
|
||||
name = "Test Plugin"
|
||||
type = "static"
|
||||
type_id = "testplugin"
|
||||
icon = "system-run"
|
||||
prefix = ":tp"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// Script that exports refresh() but doesn't call register_provider()
|
||||
fs::write(
|
||||
plugin_dir.join("main.rn"),
|
||||
r#"use owlry::Item;
|
||||
pub fn refresh() {
|
||||
[]
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let manifest =
|
||||
crate::manifest::PluginManifest::load(&plugin_dir.join("plugin.toml")).unwrap();
|
||||
let plugin = LoadedPlugin::new(manifest, plugin_dir).unwrap();
|
||||
|
||||
let regs = plugin.provider_registrations();
|
||||
assert_eq!(regs.len(), 1, "should fall back to [[providers]] declaration");
|
||||
assert_eq!(regs[0].name, "test-plugin");
|
||||
assert_eq!(regs[0].type_id, "testplugin");
|
||||
assert_eq!(regs[0].prefix.as_deref(), Some(":tp"));
|
||||
assert!(regs[0].is_static);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "owlry"
|
||||
version = "1.0.1"
|
||||
version = "1.0.8"
|
||||
edition = "2024"
|
||||
rust-version = "1.90"
|
||||
description = "A lightweight, owl-themed application launcher for Wayland"
|
||||
@@ -46,6 +46,9 @@ dirs = "5"
|
||||
# Semantic versioning (needed by plugin commands)
|
||||
semver = "1"
|
||||
|
||||
# Async oneshot channel (background thread -> main loop)
|
||||
futures-channel = "0.3"
|
||||
|
||||
[build-dependencies]
|
||||
# GResource compilation for bundled icons
|
||||
glib-build-tools = "0.20"
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
fn main() {
|
||||
// Compile GResource bundle for icons
|
||||
// Compile GResource bundle for plugin-specific icons (weather, media, pomodoro)
|
||||
glib_build_tools::compile_resources(
|
||||
&["src/resources/icons"],
|
||||
"src/resources/icons.gresource.xml",
|
||||
"icons.gresource",
|
||||
);
|
||||
|
||||
// Rerun if icon files change
|
||||
println!("cargo:rerun-if-changed=src/resources/icons.gresource.xml");
|
||||
println!("cargo:rerun-if-changed=src/resources/icons/");
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ impl OwlryApp {
|
||||
match CoreClient::connect_or_start() {
|
||||
Ok(client) => {
|
||||
info!("Connected to owlry-core daemon");
|
||||
SearchBackend::Daemon(client)
|
||||
SearchBackend::Daemon(crate::backend::DaemonHandle::new(client))
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
@@ -91,17 +91,20 @@ impl OwlryApp {
|
||||
.iter()
|
||||
.map(|s| ProviderFilter::mode_string_to_provider_type(s))
|
||||
.collect();
|
||||
let tabs = &config.borrow().general.tabs.clone();
|
||||
if provider_types.len() == 1 {
|
||||
ProviderFilter::new(
|
||||
Some(provider_types[0].clone()),
|
||||
None,
|
||||
&config.borrow().providers,
|
||||
tabs,
|
||||
)
|
||||
} else {
|
||||
ProviderFilter::new(None, Some(provider_types), &config.borrow().providers)
|
||||
ProviderFilter::new(None, Some(provider_types), &config.borrow().providers, tabs)
|
||||
}
|
||||
} else {
|
||||
ProviderFilter::new(None, None, &config.borrow().providers)
|
||||
let tabs = config.borrow().general.tabs.clone();
|
||||
ProviderFilter::new(None, None, &config.borrow().providers, &tabs)
|
||||
};
|
||||
let filter = Rc::new(RefCell::new(filter));
|
||||
|
||||
@@ -135,6 +138,9 @@ impl OwlryApp {
|
||||
Self::load_css(&config.borrow());
|
||||
|
||||
window.present();
|
||||
|
||||
// Populate results AFTER present() so the window appears immediately
|
||||
window.schedule_initial_results();
|
||||
}
|
||||
|
||||
/// Create a local backend as fallback when daemon is unavailable.
|
||||
@@ -182,16 +188,25 @@ impl OwlryApp {
|
||||
}
|
||||
|
||||
fn setup_icon_theme() {
|
||||
// Ensure we have icon fallbacks for weather/media icons
|
||||
// These may not exist in all icon themes
|
||||
if let Some(display) = gtk4::gdk::Display::default() {
|
||||
let icon_theme = gtk4::IconTheme::for_display(&display);
|
||||
|
||||
// Add Adwaita as fallback search path (has weather and media icons)
|
||||
icon_theme.add_search_path("/usr/share/icons/Adwaita");
|
||||
icon_theme.add_search_path("/usr/share/icons/breeze");
|
||||
// If the system icon theme doesn't exist on disk (e.g., set in
|
||||
// gsettings but not installed), GTK falls back to hicolor which
|
||||
// has almost no icons. Detect this and use Adwaita instead.
|
||||
let theme_name = icon_theme.theme_name();
|
||||
let theme_exists = icon_theme
|
||||
.search_path()
|
||||
.iter()
|
||||
.any(|p| p.join(theme_name.as_str()).is_dir());
|
||||
|
||||
debug!("Icon theme search paths configured with Adwaita/breeze fallbacks");
|
||||
if !theme_exists && theme_name != "hicolor" && theme_name != "Adwaita" {
|
||||
info!(
|
||||
"Icon theme '{}' not found on disk, falling back to Adwaita",
|
||||
theme_name
|
||||
);
|
||||
icon_theme.set_theme_name(Some("Adwaita"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,13 +9,88 @@ 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};
|
||||
use owlry_core::providers::{ItemSource, LaunchItem, ProviderManager, ProviderType};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Parameters needed to run a search query on a background thread.
|
||||
pub struct QueryParams {
|
||||
pub query: String,
|
||||
#[allow(dead_code)]
|
||||
pub max_results: usize,
|
||||
pub modes: Option<Vec<String>>,
|
||||
pub tag_filter: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of an async search, sent back to the main thread.
|
||||
pub struct QueryResult {
|
||||
#[allow(dead_code)]
|
||||
pub query: String,
|
||||
pub items: Vec<LaunchItem>,
|
||||
}
|
||||
|
||||
/// Thread-safe handle to the daemon IPC connection.
|
||||
pub struct DaemonHandle {
|
||||
pub(crate) client: Arc<Mutex<CoreClient>>,
|
||||
}
|
||||
|
||||
impl DaemonHandle {
|
||||
pub fn new(client: CoreClient) -> Self {
|
||||
Self {
|
||||
client: Arc::new(Mutex::new(client)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch an IPC query on a background thread.
|
||||
///
|
||||
/// Returns a `futures_channel::oneshot::Receiver` that resolves with
|
||||
/// the `QueryResult` once the background thread completes IPC. The
|
||||
/// caller should `.await` it inside `glib::spawn_future_local` to
|
||||
/// process results on the GTK main thread without `Send` constraints.
|
||||
pub fn query_async(
|
||||
&self,
|
||||
params: QueryParams,
|
||||
) -> futures_channel::oneshot::Receiver<QueryResult> {
|
||||
let (tx, rx) = futures_channel::oneshot::channel();
|
||||
let client = Arc::clone(&self.client);
|
||||
let query_for_result = params.query.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let items = match client.lock() {
|
||||
Ok(mut c) => {
|
||||
let effective_query = if let Some(ref tag) = params.tag_filter {
|
||||
format!(":tag:{} {}", tag, params.query)
|
||||
} else {
|
||||
params.query
|
||||
};
|
||||
match c.query(&effective_query, params.modes) {
|
||||
Ok(items) => items.into_iter().map(result_to_launch_item).collect(),
|
||||
Err(e) => {
|
||||
warn!("IPC query failed: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to lock daemon client: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
let _ = tx.send(QueryResult {
|
||||
query: query_for_result,
|
||||
items,
|
||||
});
|
||||
});
|
||||
|
||||
rx
|
||||
}
|
||||
}
|
||||
|
||||
/// 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),
|
||||
Daemon(DaemonHandle),
|
||||
/// Direct local provider manager (dmenu mode only)
|
||||
Local {
|
||||
providers: Box<ProviderManager>,
|
||||
@@ -24,6 +99,22 @@ pub enum SearchBackend {
|
||||
}
|
||||
|
||||
impl SearchBackend {
|
||||
/// Build the modes parameter from a ProviderFilter.
|
||||
/// When accept_all, returns None so the daemon doesn't restrict to a specific set
|
||||
/// (otherwise dynamically loaded plugin types would be filtered out).
|
||||
fn build_modes_param(filter: &ProviderFilter) -> Option<Vec<String>> {
|
||||
if filter.is_accept_all() {
|
||||
None
|
||||
} else {
|
||||
let modes: Vec<String> = filter
|
||||
.enabled_providers()
|
||||
.iter()
|
||||
.map(|p| p.to_string())
|
||||
.collect();
|
||||
if modes.is_empty() { None } else { Some(modes) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Search for items matching the query.
|
||||
///
|
||||
/// In daemon mode, sends query over IPC. The modes list is derived from
|
||||
@@ -38,24 +129,18 @@ impl SearchBackend {
|
||||
config: &Config,
|
||||
) -> Vec<LaunchItem> {
|
||||
match self {
|
||||
SearchBackend::Daemon(client) => {
|
||||
// When accept_all, send None so daemon doesn't restrict to a specific set
|
||||
// (otherwise dynamically loaded plugin types would be filtered out)
|
||||
let modes_param = if filter.is_accept_all() {
|
||||
None
|
||||
} else {
|
||||
let modes: Vec<String> = filter
|
||||
.enabled_providers()
|
||||
.iter()
|
||||
.map(|p| p.to_string())
|
||||
.collect();
|
||||
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(),
|
||||
SearchBackend::Daemon(handle) => {
|
||||
let modes_param = Self::build_modes_param(filter);
|
||||
match handle.client.lock() {
|
||||
Ok(mut client) => 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()
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
warn!("IPC query failed: {}", e);
|
||||
warn!("Failed to lock daemon client: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
@@ -82,7 +167,7 @@ impl SearchBackend {
|
||||
.collect()
|
||||
} else {
|
||||
providers
|
||||
.search_filtered(query, max_results, filter)
|
||||
.search_filtered(query, max_results, filter, None)
|
||||
.into_iter()
|
||||
.map(|(item, _)| item)
|
||||
.collect()
|
||||
@@ -101,32 +186,24 @@ impl SearchBackend {
|
||||
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.
|
||||
SearchBackend::Daemon(handle) => {
|
||||
let effective_query = if let Some(tag) = tag_filter {
|
||||
format!(":tag:{} {}", tag, query)
|
||||
} else {
|
||||
query.to_string()
|
||||
};
|
||||
|
||||
// When accept_all, send None so daemon doesn't restrict to a specific set
|
||||
// (otherwise dynamically loaded plugin types would be filtered out)
|
||||
let modes_param = if filter.is_accept_all() {
|
||||
None
|
||||
} else {
|
||||
let modes: Vec<String> = filter
|
||||
.enabled_providers()
|
||||
.iter()
|
||||
.map(|p| p.to_string())
|
||||
.collect();
|
||||
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(),
|
||||
let modes_param = Self::build_modes_param(filter);
|
||||
match handle.client.lock() {
|
||||
Ok(mut client) => 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()
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
warn!("IPC query failed: {}", e);
|
||||
warn!("Failed to lock daemon client: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
@@ -153,7 +230,7 @@ impl SearchBackend {
|
||||
.collect()
|
||||
} else {
|
||||
providers
|
||||
.search_filtered(query, max_results, filter)
|
||||
.search_filtered(query, max_results, filter, tag_filter)
|
||||
.into_iter()
|
||||
.map(|(item, _)| item)
|
||||
.collect()
|
||||
@@ -162,13 +239,43 @@ impl SearchBackend {
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch async search (daemon mode only).
|
||||
/// Returns `Some(Receiver)` if dispatched, `None` for local mode.
|
||||
pub fn query_async(
|
||||
&self,
|
||||
query: &str,
|
||||
max_results: usize,
|
||||
filter: &ProviderFilter,
|
||||
_config: &Config,
|
||||
tag_filter: Option<&str>,
|
||||
) -> Option<futures_channel::oneshot::Receiver<QueryResult>> {
|
||||
match self {
|
||||
SearchBackend::Daemon(handle) => {
|
||||
let params = QueryParams {
|
||||
query: query.to_string(),
|
||||
max_results,
|
||||
modes: Self::build_modes_param(filter),
|
||||
tag_filter: tag_filter.map(|s| s.to_string()),
|
||||
};
|
||||
Some(handle.query_async(params))
|
||||
}
|
||||
SearchBackend::Local { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
SearchBackend::Daemon(handle) => match handle.client.lock() {
|
||||
Ok(mut client) => match client.plugin_action(command) {
|
||||
Ok(handled) => handled,
|
||||
Err(e) => {
|
||||
warn!("IPC plugin_action failed: {}", e);
|
||||
false
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
warn!("IPC plugin_action failed: {}", e);
|
||||
warn!("Failed to lock daemon client: {}", e);
|
||||
false
|
||||
}
|
||||
},
|
||||
@@ -185,15 +292,21 @@ impl SearchBackend {
|
||||
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,
|
||||
SearchBackend::Daemon(handle) => match handle.client.lock() {
|
||||
Ok(mut 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
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
warn!("IPC submenu query failed: {}", e);
|
||||
warn!("Failed to lock daemon client: {}", e);
|
||||
None
|
||||
}
|
||||
},
|
||||
@@ -206,9 +319,13 @@ impl SearchBackend {
|
||||
/// 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::Daemon(handle) => {
|
||||
if let Ok(mut client) = handle.client.lock() {
|
||||
if let Err(e) = client.launch(item_id, provider) {
|
||||
warn!("IPC launch notification failed: {}", e);
|
||||
}
|
||||
} else {
|
||||
warn!("Failed to lock daemon client for launch");
|
||||
}
|
||||
}
|
||||
SearchBackend::Local { frecency, .. } => {
|
||||
@@ -236,10 +353,16 @@ impl SearchBackend {
|
||||
#[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(),
|
||||
SearchBackend::Daemon(handle) => match handle.client.lock() {
|
||||
Ok(mut client) => match client.providers() {
|
||||
Ok(descs) => descs.into_iter().map(|d| d.id).collect(),
|
||||
Err(e) => {
|
||||
warn!("IPC providers query failed: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
warn!("IPC providers query failed: {}", e);
|
||||
warn!("Failed to lock daemon client: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
},
|
||||
@@ -255,6 +378,7 @@ impl SearchBackend {
|
||||
/// 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);
|
||||
let source: ItemSource = item.source.parse().unwrap_or(ItemSource::Core);
|
||||
LaunchItem {
|
||||
id: item.id,
|
||||
name: item.title,
|
||||
@@ -272,5 +396,6 @@ fn result_to_launch_item(item: ResultItem) -> LaunchItem {
|
||||
command: item.command.unwrap_or_default(),
|
||||
terminal: item.terminal,
|
||||
tags: item.tags,
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,48 @@ use std::os::unix::net::UnixStream;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use owlry_core::ipc::{ProviderDesc, Request, Response, ResultItem};
|
||||
use owlry_core::ipc::{PluginEntry, ProviderDesc, Request, Response, ResultItem};
|
||||
|
||||
/// IPC client that connects to the owlry-core daemon Unix socket
|
||||
/// Maximum allowed size for a single IPC response line (4 MiB).
|
||||
/// Larger than the request limit because responses carry result sets.
|
||||
const MAX_RESPONSE_SIZE: usize = 4_194_304;
|
||||
|
||||
/// Read a newline-terminated line from `reader` without allocating beyond `max` bytes.
|
||||
fn read_bounded_line(reader: &mut BufReader<UnixStream>, max: usize) -> io::Result<Option<String>> {
|
||||
let mut buf: Vec<u8> = Vec::with_capacity(4096);
|
||||
loop {
|
||||
let available = reader.fill_buf()?;
|
||||
if available.is_empty() {
|
||||
return if buf.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(String::from_utf8_lossy(&buf).into_owned()))
|
||||
};
|
||||
}
|
||||
if let Some(pos) = available.iter().position(|&b| b == b'\n') {
|
||||
if buf.len() + pos > max {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("response too large (exceeded {} bytes)", max),
|
||||
));
|
||||
}
|
||||
buf.extend_from_slice(&available[..pos]);
|
||||
reader.consume(pos + 1);
|
||||
return Ok(Some(String::from_utf8_lossy(&buf).into_owned()));
|
||||
}
|
||||
let len = available.len();
|
||||
if buf.len() + len > max {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("response too large (exceeded {} bytes)", max),
|
||||
));
|
||||
}
|
||||
buf.extend_from_slice(available);
|
||||
reader.consume(len);
|
||||
}
|
||||
}
|
||||
|
||||
/// IPC client that connects to the owlryd daemon Unix socket
|
||||
/// and provides typed methods for all IPC operations.
|
||||
pub struct CoreClient {
|
||||
stream: UnixStream,
|
||||
@@ -38,15 +77,15 @@ impl CoreClient {
|
||||
|
||||
// Socket not available — try to start the daemon.
|
||||
let status = std::process::Command::new("systemctl")
|
||||
.args(["--user", "start", "owlry-core"])
|
||||
.args(["--user", "start", "owlryd"])
|
||||
.status()
|
||||
.map_err(|e| {
|
||||
io::Error::other(format!("failed to start owlry-core via systemd: {e}"))
|
||||
io::Error::other(format!("failed to start owlryd via systemd: {e}"))
|
||||
})?;
|
||||
|
||||
if !status.success() {
|
||||
return Err(io::Error::other(format!(
|
||||
"systemctl --user start owlry-core exited with status {}",
|
||||
"systemctl --user start owlryd exited with status {}",
|
||||
status
|
||||
)));
|
||||
}
|
||||
@@ -157,6 +196,19 @@ impl CoreClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Query the daemon's native plugin registry (loaded + suppressed entries).
|
||||
pub fn plugin_list(&mut self) -> io::Result<Vec<PluginEntry>> {
|
||||
self.send(&Request::PluginList)?;
|
||||
match self.receive()? {
|
||||
Response::PluginList { entries } => Ok(entries),
|
||||
Response::Error { message } => Err(io::Error::other(message)),
|
||||
other => Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("unexpected response to PluginList: {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 {
|
||||
@@ -186,14 +238,15 @@ impl CoreClient {
|
||||
}
|
||||
|
||||
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",
|
||||
));
|
||||
}
|
||||
let line = match read_bounded_line(&mut self.reader, MAX_RESPONSE_SIZE)? {
|
||||
Some(l) => l,
|
||||
None => {
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -256,6 +309,7 @@ mod tests {
|
||||
command: Some("firefox".into()),
|
||||
terminal: false,
|
||||
tags: vec![],
|
||||
source: "app".into(),
|
||||
}],
|
||||
};
|
||||
|
||||
@@ -327,6 +381,7 @@ mod tests {
|
||||
command: Some("systemctl --user start foo".into()),
|
||||
terminal: false,
|
||||
tags: vec![],
|
||||
source: "native_plugin".into(),
|
||||
}],
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::cli::{PluginCommand as CliPluginCommand, PluginRuntime};
|
||||
use crate::client::CoreClient;
|
||||
use owlry_core::config::Config;
|
||||
use owlry_core::paths;
|
||||
use owlry_core::plugins::manifest::{PluginManifest, discover_plugins};
|
||||
@@ -135,48 +136,53 @@ fn cmd_list_installed(
|
||||
json_output: bool,
|
||||
) -> CommandResult {
|
||||
let plugins_dir = paths::plugins_dir().ok_or("Could not determine plugins directory")?;
|
||||
|
||||
if !plugins_dir.exists() {
|
||||
if json_output {
|
||||
println!("[]");
|
||||
} else {
|
||||
println!("No plugins installed.");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let discovered = discover_plugins(&plugins_dir).map_err(|e| e.to_string())?;
|
||||
let config = Config::load().unwrap_or_default();
|
||||
let disabled_list = &config.plugins.disabled_plugins;
|
||||
|
||||
let lua_available = lua_runtime_available();
|
||||
let rune_available = rune_runtime_available();
|
||||
|
||||
let mut plugins: Vec<_> = discovered
|
||||
.iter()
|
||||
.map(|(id, (manifest, _path))| {
|
||||
let is_disabled = disabled_list.contains(id);
|
||||
let runtime = detect_runtime(manifest);
|
||||
(id.clone(), manifest.clone(), is_disabled, runtime)
|
||||
})
|
||||
.collect();
|
||||
// ── Script plugins (from filesystem) ────────────────────────────────
|
||||
let mut script_plugins: Vec<_> = if plugins_dir.exists() {
|
||||
let discovered = discover_plugins(&plugins_dir).map_err(|e| e.to_string())?;
|
||||
discovered
|
||||
.into_iter()
|
||||
.map(|(id, (manifest, _path))| {
|
||||
let is_disabled = disabled_list.contains(&id);
|
||||
let runtime = detect_runtime(&manifest);
|
||||
(id, manifest, is_disabled, runtime)
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// Apply filters
|
||||
// Apply filters to script plugins
|
||||
if only_enabled {
|
||||
plugins.retain(|(_, _, is_disabled, _)| !*is_disabled);
|
||||
script_plugins.retain(|(_, _, is_disabled, _)| !*is_disabled);
|
||||
}
|
||||
if only_disabled {
|
||||
plugins.retain(|(_, _, is_disabled, _)| *is_disabled);
|
||||
script_plugins.retain(|(_, _, is_disabled, _)| *is_disabled);
|
||||
}
|
||||
if let Some(rt) = runtime_filter {
|
||||
plugins.retain(|(_, _, _, runtime)| *runtime == rt);
|
||||
if let Some(ref rt) = runtime_filter {
|
||||
let rt_clone = *rt;
|
||||
script_plugins.retain(|(_, _, _, runtime)| *runtime == rt_clone);
|
||||
}
|
||||
script_plugins.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
// Sort by ID
|
||||
plugins.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
// ── Native plugins (from daemon, if running) ─────────────────────────
|
||||
// Skip native plugins if a runtime filter is active (they have no script runtime).
|
||||
let native_entries = if runtime_filter.is_none() {
|
||||
CoreClient::connect(&CoreClient::socket_path())
|
||||
.ok()
|
||||
.and_then(|mut client| client.plugin_list().ok())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// ── Output ───────────────────────────────────────────────────────────
|
||||
if json_output {
|
||||
let json_list: Vec<_> = plugins
|
||||
let mut json_list: Vec<_> = script_plugins
|
||||
.iter()
|
||||
.map(|(id, manifest, is_disabled, runtime)| {
|
||||
let runtime_available = match runtime {
|
||||
@@ -191,39 +197,82 @@ fn cmd_list_installed(
|
||||
"enabled": !is_disabled,
|
||||
"runtime": runtime.to_string(),
|
||||
"runtime_available": runtime_available,
|
||||
"source": "script",
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
println!("{}", serde_json::to_string_pretty(&json_list).unwrap());
|
||||
} else if plugins.is_empty() {
|
||||
println!("No plugins found.");
|
||||
} else {
|
||||
println!("Installed plugins:\n");
|
||||
for (id, manifest, is_disabled, runtime) in &plugins {
|
||||
let status = if *is_disabled { " (disabled)" } else { "" };
|
||||
let runtime_available = match runtime {
|
||||
PluginRuntime::Lua => lua_available,
|
||||
PluginRuntime::Rune => rune_available,
|
||||
};
|
||||
let runtime_status = if !runtime_available {
|
||||
format!(" [{} - NOT INSTALLED]", runtime)
|
||||
} else {
|
||||
format!(" [{}]", runtime)
|
||||
};
|
||||
println!(
|
||||
" {} v{}{}{}\n {}",
|
||||
id,
|
||||
manifest.plugin.version,
|
||||
status,
|
||||
runtime_status,
|
||||
if manifest.plugin.description.is_empty() {
|
||||
"No description"
|
||||
} else {
|
||||
&manifest.plugin.description
|
||||
}
|
||||
);
|
||||
for entry in &native_entries {
|
||||
json_list.push(serde_json::json!({
|
||||
"id": entry.id,
|
||||
"name": entry.name,
|
||||
"version": entry.version,
|
||||
"status": entry.status,
|
||||
"status_detail": entry.status_detail,
|
||||
"runtime": entry.runtime,
|
||||
"providers": entry.providers,
|
||||
"source": "native",
|
||||
}));
|
||||
}
|
||||
println!("\n{} plugin(s) installed.", plugins.len());
|
||||
println!("{}", serde_json::to_string_pretty(&json_list).unwrap());
|
||||
} else {
|
||||
let total = script_plugins.len() + native_entries.len();
|
||||
if total == 0 {
|
||||
println!("No plugins found.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !script_plugins.is_empty() {
|
||||
println!("Script plugins:\n");
|
||||
for (id, manifest, is_disabled, runtime) in &script_plugins {
|
||||
let status = if *is_disabled { " (disabled)" } else { "" };
|
||||
let runtime_available = match runtime {
|
||||
PluginRuntime::Lua => lua_available,
|
||||
PluginRuntime::Rune => rune_available,
|
||||
};
|
||||
let runtime_status = if !runtime_available {
|
||||
format!(" [{} - NOT INSTALLED]", runtime)
|
||||
} else {
|
||||
format!(" [{}]", runtime)
|
||||
};
|
||||
println!(
|
||||
" {} v{}{}{}\n {}",
|
||||
id,
|
||||
manifest.plugin.version,
|
||||
status,
|
||||
runtime_status,
|
||||
if manifest.plugin.description.is_empty() {
|
||||
"No description"
|
||||
} else {
|
||||
&manifest.plugin.description
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if !native_entries.is_empty() {
|
||||
if !script_plugins.is_empty() {
|
||||
println!();
|
||||
}
|
||||
println!("Native plugins:\n");
|
||||
for entry in &native_entries {
|
||||
let status_label = if entry.status == "suppressed" {
|
||||
format!(" (suppressed: {})", entry.status_detail)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let providers_label = if entry.providers.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" [{}]", entry.providers.join(", "))
|
||||
};
|
||||
println!(" {} v{}{}{}", entry.id, entry.version, status_label, providers_label);
|
||||
if !entry.name.is_empty() && entry.name != entry.id {
|
||||
println!(" {}", entry.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n{} plugin(s) total.", total);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -366,6 +415,14 @@ fn cmd_info_installed(name: &str, json_output: bool) -> CommandResult {
|
||||
"runtime": runtime.to_string(),
|
||||
"runtime_available": runtime_available,
|
||||
"path": plugin_path.display().to_string(),
|
||||
"providers": manifest.providers.iter().map(|p| serde_json::json!({
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"type": p.provider_type,
|
||||
"type_id": p.type_id,
|
||||
"prefix": p.prefix,
|
||||
"icon": p.icon,
|
||||
})).collect::<Vec<_>>(),
|
||||
"provides": {
|
||||
"providers": manifest.provides.providers,
|
||||
"actions": manifest.provides.actions,
|
||||
@@ -406,9 +463,13 @@ fn cmd_info_installed(name: &str, json_output: bool) -> CommandResult {
|
||||
);
|
||||
println!("Path: {}", plugin_path.display());
|
||||
println!();
|
||||
println!("Provides:");
|
||||
println!("Providers:");
|
||||
for p in &manifest.providers {
|
||||
let prefix = p.prefix.as_deref().map(|s| format!(" ({})", s)).unwrap_or_default();
|
||||
println!(" {} [{}]{}", p.name, p.provider_type, prefix);
|
||||
}
|
||||
if !manifest.provides.providers.is_empty() {
|
||||
println!(" Providers: {}", manifest.provides.providers.join(", "));
|
||||
println!(" {}", manifest.provides.providers.join(", "));
|
||||
}
|
||||
if manifest.provides.actions {
|
||||
println!(" Actions: yes");
|
||||
@@ -754,10 +815,16 @@ fn cmd_create(
|
||||
let desc = description.unwrap_or("A custom owlry plugin");
|
||||
|
||||
let (entry_file, entry_ext) = match runtime {
|
||||
PluginRuntime::Lua => ("init.lua", "lua"),
|
||||
PluginRuntime::Rune => ("init.rn", "rn"),
|
||||
PluginRuntime::Lua => ("main.lua", "lua"),
|
||||
PluginRuntime::Rune => ("main.rn", "rn"),
|
||||
};
|
||||
|
||||
// Derive a short type_id from the plugin name (strip common prefixes)
|
||||
let type_id = name
|
||||
.strip_prefix("owlry-")
|
||||
.unwrap_or(name)
|
||||
.replace('-', "_");
|
||||
|
||||
// Create plugin.toml
|
||||
let manifest = format!(
|
||||
r#"[plugin]
|
||||
@@ -765,25 +832,21 @@ id = "{name}"
|
||||
name = "{display}"
|
||||
version = "0.1.0"
|
||||
description = "{desc}"
|
||||
author = ""
|
||||
owlry_version = ">=0.3.0"
|
||||
entry = "{entry_file}"
|
||||
entry_point = "{entry_file}"
|
||||
|
||||
[provides]
|
||||
providers = ["{name}"]
|
||||
actions = false
|
||||
themes = []
|
||||
hooks = false
|
||||
|
||||
[permissions]
|
||||
network = false
|
||||
filesystem = []
|
||||
run_commands = []
|
||||
[[providers]]
|
||||
id = "{name}"
|
||||
name = "{display}"
|
||||
type = "static"
|
||||
type_id = "{type_id}"
|
||||
icon = "application-x-addon"
|
||||
# prefix = ":{type_id}"
|
||||
"#,
|
||||
name = name,
|
||||
display = display,
|
||||
desc = desc,
|
||||
entry_file = entry_file,
|
||||
type_id = type_id,
|
||||
);
|
||||
|
||||
fs::write(plugin_dir.join("plugin.toml"), manifest)
|
||||
@@ -792,91 +855,51 @@ run_commands = []
|
||||
// Create entry point template based on runtime
|
||||
match runtime {
|
||||
PluginRuntime::Lua => {
|
||||
let init_lua = format!(
|
||||
let main_lua = format!(
|
||||
r#"-- {display} Plugin for Owlry
|
||||
-- {desc}
|
||||
|
||||
-- Register the provider
|
||||
owlry.provider.register({{
|
||||
name = "{name}",
|
||||
display_name = "{display}",
|
||||
type_id = "{name}",
|
||||
default_icon = "application-x-executable",
|
||||
|
||||
refresh = function()
|
||||
-- Return a list of items
|
||||
return {{
|
||||
{{
|
||||
id = "{name}:example",
|
||||
name = "Example Item",
|
||||
description = "This is an example item from {display}",
|
||||
icon = "dialog-information",
|
||||
command = "echo 'Hello from {name}!'",
|
||||
terminal = false,
|
||||
tags = {{}}
|
||||
}}
|
||||
}}
|
||||
end
|
||||
}})
|
||||
|
||||
owlry.log.info("{display} plugin loaded")
|
||||
function refresh()
|
||||
return {{
|
||||
{{
|
||||
id = "{name}:example",
|
||||
name = "Example Item",
|
||||
description = "This is an example item from {display}",
|
||||
icon = "dialog-information",
|
||||
command = "echo 'Hello from {name}!'",
|
||||
tags = {{}},
|
||||
}},
|
||||
}}
|
||||
end
|
||||
"#,
|
||||
name = name,
|
||||
display = display,
|
||||
desc = desc,
|
||||
);
|
||||
fs::write(plugin_dir.join(entry_file), init_lua)
|
||||
fs::write(plugin_dir.join(entry_file), main_lua)
|
||||
.map_err(|e| format!("Failed to write {}: {}", entry_file, e))?;
|
||||
}
|
||||
PluginRuntime::Rune => {
|
||||
// Note: Rune uses #{{ for object literals, so we build manually
|
||||
let init_rn = format!(
|
||||
r#"//! {display} Plugin for Owlry
|
||||
//! {desc}
|
||||
let main_rn = format!(
|
||||
r#"use owlry::Item;
|
||||
|
||||
/// Plugin item structure
|
||||
struct Item {{{{
|
||||
id: String,
|
||||
name: String,
|
||||
description: String,
|
||||
icon: String,
|
||||
command: String,
|
||||
terminal: bool,
|
||||
tags: Vec<String>,
|
||||
}}}}
|
||||
pub fn refresh() {{
|
||||
let items = [];
|
||||
|
||||
/// Provider registration
|
||||
pub fn register(owlry) {{{{
|
||||
owlry.provider.register(#{{{{
|
||||
name: "{name}",
|
||||
display_name: "{display}",
|
||||
type_id: "{name}",
|
||||
default_icon: "application-x-executable",
|
||||
items.push(
|
||||
Item::new("{name}:example", "Example Item", "echo 'Hello from {name}!'")
|
||||
.description("This is an example item from {display}")
|
||||
.icon("dialog-information")
|
||||
.keywords(["example"]),
|
||||
);
|
||||
|
||||
refresh: || {{{{
|
||||
// Return a list of items
|
||||
[
|
||||
Item {{{{
|
||||
id: "{name}:example",
|
||||
name: "Example Item",
|
||||
description: "This is an example item from {display}",
|
||||
icon: "dialog-information",
|
||||
command: "echo 'Hello from {name}!'",
|
||||
terminal: false,
|
||||
tags: [],
|
||||
}}}},
|
||||
]
|
||||
}}}},
|
||||
}}}});
|
||||
|
||||
owlry.log.info("{display} plugin loaded");
|
||||
}}}}
|
||||
items
|
||||
}}
|
||||
"#,
|
||||
name = name,
|
||||
display = display,
|
||||
desc = desc,
|
||||
);
|
||||
fs::write(plugin_dir.join(entry_file), init_rn)
|
||||
fs::write(plugin_dir.join(entry_file), main_rn)
|
||||
.map_err(|e| format!("Failed to write {}: {}", entry_file, e))?;
|
||||
}
|
||||
}
|
||||
@@ -955,13 +978,14 @@ fn cmd_validate(path: Option<&str>) -> CommandResult {
|
||||
));
|
||||
}
|
||||
|
||||
// Check for empty provides
|
||||
if manifest.provides.providers.is_empty()
|
||||
&& !manifest.provides.actions
|
||||
&& manifest.provides.themes.is_empty()
|
||||
&& !manifest.provides.hooks
|
||||
{
|
||||
warnings.push("Plugin does not provide any features".to_string());
|
||||
// Check for empty provides (accept either [[providers]] or [provides])
|
||||
let has_providers = !manifest.providers.is_empty()
|
||||
|| !manifest.provides.providers.is_empty()
|
||||
|| manifest.provides.actions
|
||||
|| !manifest.provides.themes.is_empty()
|
||||
|| manifest.provides.hooks;
|
||||
if !has_providers {
|
||||
warnings.push("Plugin does not declare any providers".to_string());
|
||||
}
|
||||
|
||||
println!(" Plugin ID: {}", manifest.plugin.id);
|
||||
@@ -1013,11 +1037,11 @@ fn cmd_runtimes() -> CommandResult {
|
||||
if lua_available {
|
||||
println!(" ✓ Lua - Installed");
|
||||
println!(" Package: owlry-lua");
|
||||
println!(" Entry point: init.lua");
|
||||
println!(" Entry point: main.lua");
|
||||
} else {
|
||||
println!(" ✗ Lua - Not installed");
|
||||
println!(" Install: yay -S owlry-lua");
|
||||
println!(" Entry point: init.lua");
|
||||
println!(" Entry point: main.lua");
|
||||
}
|
||||
|
||||
println!();
|
||||
@@ -1026,11 +1050,11 @@ fn cmd_runtimes() -> CommandResult {
|
||||
if rune_available {
|
||||
println!(" ✓ Rune - Installed");
|
||||
println!(" Package: owlry-rune");
|
||||
println!(" Entry point: init.rn");
|
||||
println!(" Entry point: main.rn");
|
||||
} else {
|
||||
println!(" ✗ Rune - Not installed");
|
||||
println!(" Install: yay -S owlry-rune");
|
||||
println!(" Entry point: init.rn");
|
||||
println!(" Entry point: main.rn");
|
||||
}
|
||||
|
||||
println!();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use log::debug;
|
||||
use owlry_core::providers::{LaunchItem, Provider, ProviderType};
|
||||
use owlry_core::providers::{ItemSource, LaunchItem, Provider, ProviderType};
|
||||
use std::io::{self, BufRead};
|
||||
|
||||
/// Provider for dmenu-style input from stdin
|
||||
@@ -102,6 +102,7 @@ impl Provider for DmenuProvider {
|
||||
command: line.to_string(),
|
||||
terminal: false,
|
||||
tags: Vec::new(),
|
||||
source: ItemSource::Core,
|
||||
};
|
||||
|
||||
self.items.push(item);
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
background-color: var(--owlry-bg, @theme_bg_color);
|
||||
border-radius: var(--owlry-border-radius, 12px);
|
||||
border: 1px solid var(--owlry-border, @borders);
|
||||
box-shadow: var(--owlry-shadow, none);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
@@ -56,6 +57,16 @@
|
||||
color: var(--owlry-accent-bright, @theme_selected_fg_color);
|
||||
}
|
||||
|
||||
/* Highlighted result row (exact match or auto-detected plugin result) */
|
||||
.owlry-result-highlight {
|
||||
background-color: alpha(var(--owlry-accent, @theme_selected_bg_color), 0.08);
|
||||
border-left: 3px solid var(--owlry-accent, @theme_selected_bg_color);
|
||||
}
|
||||
|
||||
.owlry-result-highlight:selected {
|
||||
border-left: 3px solid var(--owlry-accent-bright, @theme_selected_fg_color);
|
||||
}
|
||||
|
||||
/* Result icon */
|
||||
.owlry-result-icon {
|
||||
color: var(--owlry-text, @theme_fg_color);
|
||||
|
||||
@@ -31,8 +31,6 @@
|
||||
.owlry-main {
|
||||
background-color: rgba(26, 27, 38, 0.95);
|
||||
border: 1px solid rgba(65, 72, 104, 0.6);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(224, 175, 104, 0.1);
|
||||
}
|
||||
|
||||
/* Search entry */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::backend::SearchBackend;
|
||||
use crate::ui::ResultRow;
|
||||
use crate::ui::provider_meta;
|
||||
use crate::ui::submenu;
|
||||
use gtk4::gdk::Key;
|
||||
use gtk4::prelude::*;
|
||||
@@ -10,7 +11,7 @@ use gtk4::{
|
||||
use log::info;
|
||||
use owlry_core::config::Config;
|
||||
use owlry_core::filter::ProviderFilter;
|
||||
use owlry_core::providers::{LaunchItem, ProviderType};
|
||||
use owlry_core::providers::{ItemSource, LaunchItem, ProviderType};
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
use log::debug;
|
||||
@@ -42,6 +43,8 @@ struct LazyLoadState {
|
||||
all_results: Vec<LaunchItem>,
|
||||
/// Number of items currently displayed
|
||||
displayed_count: usize,
|
||||
/// The query that produced these results (for highlighting in lazy-loaded batches)
|
||||
query: String,
|
||||
}
|
||||
|
||||
/// Number of items to display initially and per batch
|
||||
@@ -224,7 +227,6 @@ impl MainWindow {
|
||||
|
||||
main_window.setup_signals();
|
||||
main_window.setup_lazy_loading();
|
||||
main_window.update_results("");
|
||||
|
||||
// Ensure search entry has focus when window is shown
|
||||
main_window.search_entry.grab_focus();
|
||||
@@ -240,36 +242,23 @@ impl MainWindow {
|
||||
search_entry_for_refresh.emit_by_name::<()>("changed", &[]);
|
||||
});
|
||||
|
||||
// Set up periodic widget auto-refresh (every 5 seconds) — local backend only
|
||||
// In daemon mode, the daemon handles widget refresh and results come via IPC
|
||||
if main_window.is_dmenu_mode {
|
||||
// dmenu typically has no widgets, but this is harmless
|
||||
}
|
||||
let backend_for_auto = main_window.backend.clone();
|
||||
let current_results_for_auto = main_window.current_results.clone();
|
||||
let submenu_state_for_auto = main_window.submenu_state.clone();
|
||||
let search_entry_for_auto = main_window.search_entry.clone();
|
||||
gtk4::glib::timeout_add_local(std::time::Duration::from_secs(5), move || {
|
||||
let in_submenu = submenu_state_for_auto.borrow().active;
|
||||
|
||||
// For local backend: refresh widgets (daemon handles this itself)
|
||||
backend_for_auto.borrow_mut().refresh_widgets();
|
||||
|
||||
// For daemon backend: re-query to get updated widget data
|
||||
if !in_submenu {
|
||||
if let SearchBackend::Daemon(_) = &*backend_for_auto.borrow() {
|
||||
// Trigger a re-search to pick up updated widget items from daemon
|
||||
search_entry_for_auto.emit_by_name::<()>("changed", &[]);
|
||||
} else {
|
||||
// Local backend: update widget items in-place (legacy behavior)
|
||||
// This path is only hit in dmenu mode which doesn't have widgets,
|
||||
// but keep it for completeness.
|
||||
let _results = current_results_for_auto.borrow();
|
||||
// No-op for local mode without widget access
|
||||
// Periodic widget refresh — local backend only.
|
||||
// In daemon mode, the daemon handles widget refresh internally;
|
||||
// the UI gets updated data on the next user-initiated search.
|
||||
// We do NOT re-query in daemon mode because it resets the user's
|
||||
// scroll position and selection.
|
||||
if !matches!(&*main_window.backend.borrow(), SearchBackend::Daemon(_)) {
|
||||
let backend_for_auto = main_window.backend.clone();
|
||||
let debounce_for_auto = main_window.debounce_source.clone();
|
||||
gtk4::glib::timeout_add_local(std::time::Duration::from_secs(10), move || {
|
||||
// Skip widget refresh while the user is actively typing.
|
||||
if debounce_for_auto.borrow().is_some() {
|
||||
return gtk4::glib::ControlFlow::Continue;
|
||||
}
|
||||
}
|
||||
gtk4::glib::ControlFlow::Continue
|
||||
});
|
||||
backend_for_auto.borrow_mut().refresh_widgets();
|
||||
gtk4::glib::ControlFlow::Continue
|
||||
});
|
||||
}
|
||||
|
||||
main_window
|
||||
}
|
||||
@@ -332,86 +321,26 @@ impl MainWindow {
|
||||
/// Get display label for a provider tab
|
||||
/// Core types have fixed labels; plugins derive labels from type_id
|
||||
fn provider_tab_label(provider: &ProviderType) -> &'static str {
|
||||
match provider {
|
||||
ProviderType::Application => "Apps",
|
||||
ProviderType::Command => "Cmds",
|
||||
ProviderType::Dmenu => "Dmenu",
|
||||
ProviderType::Plugin(type_id) => match type_id.as_str() {
|
||||
"bookmarks" => "Bookmarks",
|
||||
"calc" => "Calc",
|
||||
"clipboard" => "Clip",
|
||||
"emoji" => "Emoji",
|
||||
"filesearch" => "Files",
|
||||
"media" => "Media",
|
||||
"pomodoro" => "Pomo",
|
||||
"scripts" => "Scripts",
|
||||
"ssh" => "SSH",
|
||||
"system" => "System",
|
||||
"uuctl" => "uuctl",
|
||||
"weather" => "Weather",
|
||||
"websearch" => "Web",
|
||||
_ => "Plugin",
|
||||
},
|
||||
}
|
||||
provider_meta::meta_for(provider).tab_label
|
||||
}
|
||||
|
||||
/// Get CSS class for a provider
|
||||
/// Core types have fixed CSS classes; plugins derive from type_id
|
||||
fn provider_css_class(provider: &ProviderType) -> &'static str {
|
||||
match provider {
|
||||
ProviderType::Application => "owlry-filter-app",
|
||||
ProviderType::Command => "owlry-filter-cmd",
|
||||
ProviderType::Dmenu => "owlry-filter-dmenu",
|
||||
ProviderType::Plugin(type_id) => match type_id.as_str() {
|
||||
"bookmarks" => "owlry-filter-bookmark",
|
||||
"calc" => "owlry-filter-calc",
|
||||
"clipboard" => "owlry-filter-clip",
|
||||
"emoji" => "owlry-filter-emoji",
|
||||
"filesearch" => "owlry-filter-file",
|
||||
"media" => "owlry-filter-media",
|
||||
"pomodoro" => "owlry-filter-pomodoro",
|
||||
"scripts" => "owlry-filter-script",
|
||||
"ssh" => "owlry-filter-ssh",
|
||||
"system" => "owlry-filter-sys",
|
||||
"uuctl" => "owlry-filter-uuctl",
|
||||
"weather" => "owlry-filter-weather",
|
||||
"websearch" => "owlry-filter-web",
|
||||
_ => "owlry-filter-plugin",
|
||||
},
|
||||
}
|
||||
provider_meta::meta_for(provider).css_class
|
||||
}
|
||||
|
||||
fn build_placeholder(filter: &ProviderFilter) -> String {
|
||||
let active: Vec<&str> = filter
|
||||
.enabled_providers()
|
||||
.iter()
|
||||
.map(|p| match p {
|
||||
ProviderType::Application => "applications",
|
||||
ProviderType::Command => "commands",
|
||||
ProviderType::Dmenu => "options",
|
||||
ProviderType::Plugin(type_id) => match type_id.as_str() {
|
||||
"bookmarks" => "bookmarks",
|
||||
"calc" => "calculator",
|
||||
"clipboard" => "clipboard",
|
||||
"emoji" => "emoji",
|
||||
"filesearch" => "files",
|
||||
"media" => "media",
|
||||
"pomodoro" => "pomodoro",
|
||||
"scripts" => "scripts",
|
||||
"ssh" => "SSH hosts",
|
||||
"system" => "system",
|
||||
"uuctl" => "uuctl units",
|
||||
"weather" => "weather",
|
||||
"websearch" => "web",
|
||||
_ => "plugins",
|
||||
},
|
||||
})
|
||||
.map(|p| provider_meta::meta_for(p).search_noun)
|
||||
.collect();
|
||||
|
||||
format!("Search {}...", active.join(", "))
|
||||
}
|
||||
|
||||
/// Build dynamic hints based on enabled providers
|
||||
/// Build hints string for the status bar based on enabled built-in providers.
|
||||
/// Plugin trigger hints (? web, / files, etc.) are not included here since
|
||||
/// plugin availability is not tracked in ProvidersConfig.
|
||||
fn build_hints(config: &owlry_core::config::ProvidersConfig) -> String {
|
||||
let mut parts: Vec<String> = vec![
|
||||
"Tab: cycle".to_string(),
|
||||
@@ -420,45 +349,26 @@ impl MainWindow {
|
||||
"Esc: close".to_string(),
|
||||
];
|
||||
|
||||
// Add trigger hints for enabled dynamic providers
|
||||
if config.calculator {
|
||||
parts.push("= calc".to_string());
|
||||
}
|
||||
if config.websearch {
|
||||
parts.push("? web".to_string());
|
||||
if config.converter {
|
||||
parts.push("> conv".to_string());
|
||||
}
|
||||
if config.files {
|
||||
parts.push("/ files".to_string());
|
||||
}
|
||||
|
||||
// Add prefix hints for static providers
|
||||
let mut prefixes = Vec::new();
|
||||
if config.system {
|
||||
prefixes.push(":sys");
|
||||
}
|
||||
if config.emoji {
|
||||
prefixes.push(":emoji");
|
||||
}
|
||||
if config.ssh {
|
||||
prefixes.push(":ssh");
|
||||
}
|
||||
if config.clipboard {
|
||||
prefixes.push(":clip");
|
||||
}
|
||||
if config.bookmarks {
|
||||
prefixes.push(":bm");
|
||||
}
|
||||
|
||||
// Only show first few prefixes to avoid overflow
|
||||
if !prefixes.is_empty() {
|
||||
parts.push(prefixes[..prefixes.len().min(4)].join(" "));
|
||||
parts.push(":sys".to_string());
|
||||
}
|
||||
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
/// Scroll the given row into view within the scrolled window
|
||||
fn scroll_to_row(scrolled: &ScrolledWindow, results_list: &ListBox, row: &ListBoxRow) {
|
||||
fn scroll_to_row(
|
||||
scrolled: &ScrolledWindow,
|
||||
results_list: &ListBox,
|
||||
row: &ListBoxRow,
|
||||
lazy_state: &Rc<RefCell<LazyLoadState>>,
|
||||
) {
|
||||
let vadj = scrolled.vadjustment();
|
||||
|
||||
let row_index = row.index();
|
||||
@@ -470,15 +380,7 @@ impl MainWindow {
|
||||
let current_scroll = vadj.value();
|
||||
|
||||
let list_height = results_list.height() as f64;
|
||||
let row_count = {
|
||||
let mut count = 0;
|
||||
let mut child = results_list.first_child();
|
||||
while child.is_some() {
|
||||
count += 1;
|
||||
child = child.and_then(|c| c.next_sibling());
|
||||
}
|
||||
count.max(1) as f64
|
||||
};
|
||||
let row_count = lazy_state.borrow().displayed_count.max(1) as f64;
|
||||
|
||||
let row_height = list_height / row_count;
|
||||
let row_top = row_index as f64 * row_height;
|
||||
@@ -527,12 +429,10 @@ impl MainWindow {
|
||||
search_entry.set_placeholder_text(Some(&format!("Filter {} actions...", display_name)));
|
||||
|
||||
// Display actions
|
||||
while let Some(child) = results_list.first_child() {
|
||||
results_list.remove(&child);
|
||||
}
|
||||
results_list.remove_all();
|
||||
|
||||
for item in &actions {
|
||||
let row = ResultRow::new(item);
|
||||
let row = ResultRow::new(item, "");
|
||||
results_list.append(&row);
|
||||
}
|
||||
|
||||
@@ -609,12 +509,10 @@ impl MainWindow {
|
||||
.collect();
|
||||
|
||||
// Clear and repopulate
|
||||
while let Some(child) = results_list.first_child() {
|
||||
results_list.remove(&child);
|
||||
}
|
||||
results_list.remove_all();
|
||||
|
||||
for item in &filtered {
|
||||
let row = ResultRow::new(item);
|
||||
let row = ResultRow::new(item, "");
|
||||
results_list.append(&row);
|
||||
}
|
||||
|
||||
@@ -675,6 +573,11 @@ impl MainWindow {
|
||||
let filter = filter.clone();
|
||||
let lazy_state = lazy_state.clone();
|
||||
let debounce_source_for_closure = debounce_source.clone();
|
||||
let query_str = parsed.query.clone();
|
||||
let tag = parsed.tag_filter.clone();
|
||||
// Capture the raw entry text at dispatch time for staleness detection.
|
||||
let raw_text_at_dispatch = entry.text().to_string();
|
||||
let search_entry_for_stale = search_entry_for_change.clone();
|
||||
|
||||
// Schedule debounced search
|
||||
let source_id = gtk4::glib::timeout_add_local_once(
|
||||
@@ -687,40 +590,91 @@ impl MainWindow {
|
||||
let max_results = cfg.general.max_results;
|
||||
drop(cfg);
|
||||
|
||||
let results = backend.borrow_mut().search_with_tag(
|
||||
&parsed.query,
|
||||
max_results,
|
||||
&filter.borrow(),
|
||||
&config.borrow(),
|
||||
parsed.tag_filter.as_deref(),
|
||||
);
|
||||
// Try async path (daemon mode)
|
||||
let receiver = {
|
||||
let be = backend.borrow();
|
||||
let f = filter.borrow();
|
||||
let c = config.borrow();
|
||||
be.query_async(
|
||||
&query_str,
|
||||
max_results,
|
||||
&f,
|
||||
&c,
|
||||
tag.as_deref(),
|
||||
)
|
||||
};
|
||||
|
||||
// Clear existing results
|
||||
while let Some(child) = results_list.first_child() {
|
||||
results_list.remove(&child);
|
||||
}
|
||||
if let Some(rx) = receiver {
|
||||
// Daemon mode: results arrive asynchronously on the main loop.
|
||||
// spawn_future_local runs the async block on the GTK main
|
||||
// thread, so non-Send types (Rc, GTK widgets) are fine.
|
||||
let results_list_cb = results_list.clone();
|
||||
let current_results_cb = current_results.clone();
|
||||
let lazy_state_cb = lazy_state.clone();
|
||||
let query_for_highlight = query_str.clone();
|
||||
|
||||
// Lazy loading: store all results but only display initial batch
|
||||
let initial_count = INITIAL_RESULTS.min(results.len());
|
||||
{
|
||||
gtk4::glib::spawn_future_local(async move {
|
||||
if let Ok(result) = rx.await {
|
||||
// Discard stale results: the user has typed something new
|
||||
// since this query was dispatched.
|
||||
if search_entry_for_stale.text().as_str() != raw_text_at_dispatch {
|
||||
return;
|
||||
}
|
||||
results_list_cb.remove_all();
|
||||
|
||||
let items = result.items;
|
||||
let initial_count =
|
||||
INITIAL_RESULTS.min(items.len());
|
||||
|
||||
for item in items.iter().take(initial_count) {
|
||||
let row = ResultRow::new(item, &query_for_highlight);
|
||||
results_list_cb.append(&row);
|
||||
}
|
||||
|
||||
if let Some(first_row) =
|
||||
results_list_cb.row_at_index(0)
|
||||
{
|
||||
results_list_cb.select_row(Some(&first_row));
|
||||
}
|
||||
|
||||
*current_results_cb.borrow_mut() =
|
||||
items[..initial_count].to_vec();
|
||||
let mut lazy = lazy_state_cb.borrow_mut();
|
||||
lazy.all_results = items;
|
||||
lazy.displayed_count = initial_count;
|
||||
lazy.query = query_for_highlight;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Local mode (dmenu): synchronous search
|
||||
let results = backend.borrow_mut().search_with_tag(
|
||||
&query_str,
|
||||
max_results,
|
||||
&filter.borrow(),
|
||||
&config.borrow(),
|
||||
tag.as_deref(),
|
||||
);
|
||||
|
||||
results_list.remove_all();
|
||||
|
||||
let initial_count = INITIAL_RESULTS.min(results.len());
|
||||
|
||||
for item in results.iter().take(initial_count) {
|
||||
let row = ResultRow::new(item, &query_str);
|
||||
results_list.append(&row);
|
||||
}
|
||||
|
||||
if let Some(first_row) = results_list.row_at_index(0) {
|
||||
results_list.select_row(Some(&first_row));
|
||||
}
|
||||
|
||||
*current_results.borrow_mut() =
|
||||
results[..initial_count].to_vec();
|
||||
let mut lazy = lazy_state.borrow_mut();
|
||||
lazy.all_results = results.clone();
|
||||
lazy.all_results = results;
|
||||
lazy.query = query_str;
|
||||
lazy.displayed_count = initial_count;
|
||||
}
|
||||
|
||||
// Display only initial batch
|
||||
for item in results.iter().take(initial_count) {
|
||||
let row = ResultRow::new(item);
|
||||
results_list.append(&row);
|
||||
}
|
||||
|
||||
if let Some(first_row) = results_list.row_at_index(0) {
|
||||
results_list.select_row(Some(&first_row));
|
||||
}
|
||||
|
||||
// current_results holds only what's displayed (for selection/activation)
|
||||
*current_results.borrow_mut() =
|
||||
results.into_iter().take(initial_count).collect();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -856,6 +810,7 @@ impl MainWindow {
|
||||
let submenu_state = self.submenu_state.clone();
|
||||
let tab_order = self.tab_order.clone();
|
||||
let is_dmenu_mode = self.is_dmenu_mode;
|
||||
let lazy_state_for_keys = self.lazy_state.clone();
|
||||
|
||||
key_controller.connect_key_pressed(move |_, key, _, modifiers| {
|
||||
let ctrl = modifiers.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
|
||||
@@ -919,7 +874,7 @@ impl MainWindow {
|
||||
let next_index = current.index() + 1;
|
||||
if let Some(next_row) = results_list.row_at_index(next_index) {
|
||||
results_list.select_row(Some(&next_row));
|
||||
Self::scroll_to_row(&scrolled, &results_list, &next_row);
|
||||
Self::scroll_to_row(&scrolled, &results_list, &next_row, &lazy_state_for_keys);
|
||||
}
|
||||
}
|
||||
gtk4::glib::Propagation::Stop
|
||||
@@ -931,7 +886,7 @@ impl MainWindow {
|
||||
&& let Some(prev_row) = results_list.row_at_index(prev_index)
|
||||
{
|
||||
results_list.select_row(Some(&prev_row));
|
||||
Self::scroll_to_row(&scrolled, &results_list, &prev_row);
|
||||
Self::scroll_to_row(&scrolled, &results_list, &prev_row, &lazy_state_for_keys);
|
||||
}
|
||||
}
|
||||
gtk4::glib::Propagation::Stop
|
||||
@@ -1126,6 +1081,7 @@ impl MainWindow {
|
||||
for provider in tab_order {
|
||||
f.enable(provider.clone());
|
||||
}
|
||||
f.restore_all_mode();
|
||||
}
|
||||
for (_, button) in buttons.borrow().iter() {
|
||||
button.set_active(true);
|
||||
@@ -1183,43 +1139,47 @@ impl MainWindow {
|
||||
entry.emit_by_name::<()>("changed", &[]);
|
||||
}
|
||||
|
||||
fn update_results(&self, query: &str) {
|
||||
let cfg = self.config.borrow();
|
||||
let max_results = cfg.general.max_results;
|
||||
drop(cfg);
|
||||
/// Schedule initial results population via idle callback.
|
||||
/// Call this AFTER `window.present()` so the window appears immediately.
|
||||
pub fn schedule_initial_results(&self) {
|
||||
let backend = self.backend.clone();
|
||||
let results_list = self.results_list.clone();
|
||||
let config = self.config.clone();
|
||||
let filter = self.filter.clone();
|
||||
let current_results = self.current_results.clone();
|
||||
let lazy_state = self.lazy_state.clone();
|
||||
|
||||
let results = self.backend.borrow_mut().search(
|
||||
query,
|
||||
max_results,
|
||||
&self.filter.borrow(),
|
||||
&self.config.borrow(),
|
||||
);
|
||||
gtk4::glib::idle_add_local_once(move || {
|
||||
let cfg = config.borrow();
|
||||
let max_results = cfg.general.max_results;
|
||||
drop(cfg);
|
||||
|
||||
// Clear existing results
|
||||
while let Some(child) = self.results_list.first_child() {
|
||||
self.results_list.remove(&child);
|
||||
}
|
||||
let results = backend.borrow_mut().search(
|
||||
"",
|
||||
max_results,
|
||||
&filter.borrow(),
|
||||
&config.borrow(),
|
||||
);
|
||||
|
||||
// Store all results for lazy loading
|
||||
let initial_count = INITIAL_RESULTS.min(results.len());
|
||||
{
|
||||
let mut lazy = self.lazy_state.borrow_mut();
|
||||
lazy.all_results = results.clone();
|
||||
// Clear existing results
|
||||
results_list.remove_all();
|
||||
|
||||
let initial_count = INITIAL_RESULTS.min(results.len());
|
||||
|
||||
for item in results.iter().take(initial_count) {
|
||||
let row = ResultRow::new(item, "");
|
||||
results_list.append(&row);
|
||||
}
|
||||
|
||||
if let Some(first_row) = results_list.row_at_index(0) {
|
||||
results_list.select_row(Some(&first_row));
|
||||
}
|
||||
|
||||
*current_results.borrow_mut() = results[..initial_count].to_vec();
|
||||
let mut lazy = lazy_state.borrow_mut();
|
||||
lazy.all_results = results;
|
||||
lazy.displayed_count = initial_count;
|
||||
}
|
||||
|
||||
// Display initial batch only
|
||||
for item in results.iter().take(initial_count) {
|
||||
let row = ResultRow::new(item);
|
||||
self.results_list.append(&row);
|
||||
}
|
||||
|
||||
if let Some(first_row) = self.results_list.row_at_index(0) {
|
||||
self.results_list.select_row(Some(&first_row));
|
||||
}
|
||||
|
||||
// current_results holds what's currently displayed
|
||||
*self.current_results.borrow_mut() = results.into_iter().take(initial_count).collect();
|
||||
});
|
||||
}
|
||||
|
||||
/// Set up lazy loading scroll detection
|
||||
@@ -1276,8 +1236,9 @@ impl MainWindow {
|
||||
if displayed < all_count {
|
||||
// Load next batch
|
||||
let new_end = (displayed + LOAD_MORE_BATCH).min(all_count);
|
||||
let query = lazy.query.clone();
|
||||
for item in lazy.all_results[displayed..new_end].iter() {
|
||||
let row = ResultRow::new(item);
|
||||
let row = ResultRow::new(item, &query);
|
||||
results_list.append(&row);
|
||||
}
|
||||
lazy.displayed_count = new_end;
|
||||
@@ -1330,6 +1291,36 @@ impl MainWindow {
|
||||
item.terminal, item.provider, item.id
|
||||
);
|
||||
|
||||
// Reject script plugin commands that don't match the known-safe allowlist.
|
||||
// Script plugins (Lua/Rune user plugins) are untrusted code; only allow
|
||||
// patterns that can't escalate privileges or exfiltrate data.
|
||||
if item.source == ItemSource::ScriptPlugin {
|
||||
let cmd = &item.command;
|
||||
let allowed = cmd.is_empty()
|
||||
|| cmd.starts_with("xdg-open ")
|
||||
|| cmd.starts_with("wl-copy")
|
||||
|| cmd.starts_with("wl-paste")
|
||||
|| cmd.starts_with("SUBMENU:")
|
||||
|| cmd.starts_with('!');
|
||||
if !allowed {
|
||||
let msg = format!(
|
||||
"Blocked untrusted script plugin command from '{}': {}",
|
||||
item.name, cmd
|
||||
);
|
||||
log::warn!("{}", msg);
|
||||
owlry_core::notify::notify("Command blocked", &msg);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Reject items with no command — nothing to execute.
|
||||
if item.command.is_empty() && !matches!(item.provider, ProviderType::Application) {
|
||||
let msg = format!("Item '{}' has no command; cannot launch", item.name);
|
||||
log::warn!("{}", msg);
|
||||
owlry_core::notify::notify("Launch failed", &msg);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a desktop application (has .desktop file as ID)
|
||||
let is_desktop_app =
|
||||
matches!(item.provider, ProviderType::Application) && item.id.ends_with(".desktop");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod main_window;
|
||||
pub mod provider_meta;
|
||||
mod result_row;
|
||||
pub mod submenu;
|
||||
|
||||
|
||||
101
crates/owlry/src/ui/provider_meta.rs
Normal file
101
crates/owlry/src/ui/provider_meta.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
use owlry_core::providers::ProviderType;
|
||||
|
||||
/// Display metadata for a provider.
|
||||
pub struct ProviderMeta {
|
||||
pub tab_label: &'static str,
|
||||
pub css_class: &'static str,
|
||||
pub search_noun: &'static str,
|
||||
}
|
||||
|
||||
/// Return display metadata for a provider type.
|
||||
pub fn meta_for(provider: &ProviderType) -> ProviderMeta {
|
||||
match provider {
|
||||
ProviderType::Application => ProviderMeta {
|
||||
tab_label: "Apps",
|
||||
css_class: "owlry-filter-app",
|
||||
search_noun: "applications",
|
||||
},
|
||||
ProviderType::Command => ProviderMeta {
|
||||
tab_label: "Cmds",
|
||||
css_class: "owlry-filter-cmd",
|
||||
search_noun: "commands",
|
||||
},
|
||||
ProviderType::Dmenu => ProviderMeta {
|
||||
tab_label: "Dmenu",
|
||||
css_class: "owlry-filter-dmenu",
|
||||
search_noun: "options",
|
||||
},
|
||||
ProviderType::Plugin(type_id) => match type_id.as_str() {
|
||||
"bookmarks" => ProviderMeta {
|
||||
tab_label: "Bookmarks",
|
||||
css_class: "owlry-filter-bookmark",
|
||||
search_noun: "bookmarks",
|
||||
},
|
||||
"calc" => ProviderMeta {
|
||||
tab_label: "Calc",
|
||||
css_class: "owlry-filter-calc",
|
||||
search_noun: "calculator",
|
||||
},
|
||||
"clipboard" => ProviderMeta {
|
||||
tab_label: "Clip",
|
||||
css_class: "owlry-filter-clip",
|
||||
search_noun: "clipboard",
|
||||
},
|
||||
"emoji" => ProviderMeta {
|
||||
tab_label: "Emoji",
|
||||
css_class: "owlry-filter-emoji",
|
||||
search_noun: "emoji",
|
||||
},
|
||||
"filesearch" => ProviderMeta {
|
||||
tab_label: "Files",
|
||||
css_class: "owlry-filter-file",
|
||||
search_noun: "files",
|
||||
},
|
||||
"media" => ProviderMeta {
|
||||
tab_label: "Media",
|
||||
css_class: "owlry-filter-media",
|
||||
search_noun: "media",
|
||||
},
|
||||
"pomodoro" => ProviderMeta {
|
||||
tab_label: "Pomo",
|
||||
css_class: "owlry-filter-pomodoro",
|
||||
search_noun: "pomodoro",
|
||||
},
|
||||
"scripts" => ProviderMeta {
|
||||
tab_label: "Scripts",
|
||||
css_class: "owlry-filter-script",
|
||||
search_noun: "scripts",
|
||||
},
|
||||
"ssh" => ProviderMeta {
|
||||
tab_label: "SSH",
|
||||
css_class: "owlry-filter-ssh",
|
||||
search_noun: "SSH hosts",
|
||||
},
|
||||
"system" => ProviderMeta {
|
||||
tab_label: "System",
|
||||
css_class: "owlry-filter-sys",
|
||||
search_noun: "system",
|
||||
},
|
||||
"uuctl" => ProviderMeta {
|
||||
tab_label: "uuctl",
|
||||
css_class: "owlry-filter-uuctl",
|
||||
search_noun: "uuctl units",
|
||||
},
|
||||
"weather" => ProviderMeta {
|
||||
tab_label: "Weather",
|
||||
css_class: "owlry-filter-weather",
|
||||
search_noun: "weather",
|
||||
},
|
||||
"websearch" => ProviderMeta {
|
||||
tab_label: "Web",
|
||||
css_class: "owlry-filter-web",
|
||||
search_noun: "web",
|
||||
},
|
||||
_ => ProviderMeta {
|
||||
tab_label: "Plugin",
|
||||
css_class: "owlry-filter-plugin",
|
||||
search_noun: "plugins",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{Box as GtkBox, Image, Label, ListBoxRow, Orientation, Widget};
|
||||
use owlry_core::providers::LaunchItem;
|
||||
use owlry_core::providers::{LaunchItem, ProviderType};
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct ResultRow {
|
||||
@@ -18,9 +18,31 @@ fn is_emoji_icon(s: &str) -> bool {
|
||||
!first_char.is_ascii() && s.chars().count() <= 8
|
||||
}
|
||||
|
||||
/// Check if this item should be highlighted based on the query.
|
||||
/// Highlighted when:
|
||||
/// - Item is from an auto-detecting plugin (calculator, converter) that parsed
|
||||
/// the query into a result — these produce direct answers, not search results
|
||||
/// - Item name exactly matches the query (case-insensitive)
|
||||
fn should_highlight(item: &LaunchItem, query: &str) -> bool {
|
||||
if query.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exact name match (case-insensitive)
|
||||
if item.name.eq_ignore_ascii_case(query) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Auto-detect plugins that produce direct answers (not search tools)
|
||||
matches!(
|
||||
&item.provider,
|
||||
ProviderType::Plugin(id) if matches!(id.as_str(), "calc" | "conv")
|
||||
)
|
||||
}
|
||||
|
||||
impl ResultRow {
|
||||
#[allow(clippy::new_ret_no_self)]
|
||||
pub fn new(item: &LaunchItem) -> ListBoxRow {
|
||||
pub fn new(item: &LaunchItem, query: &str) -> ListBoxRow {
|
||||
let row = ListBoxRow::builder()
|
||||
.selectable(true)
|
||||
.activatable(true)
|
||||
@@ -28,6 +50,10 @@ impl ResultRow {
|
||||
|
||||
row.add_css_class("owlry-result-row");
|
||||
|
||||
if should_highlight(item, query) {
|
||||
row.add_css_class("owlry-result-highlight");
|
||||
}
|
||||
|
||||
let hbox = GtkBox::builder()
|
||||
.orientation(Orientation::Horizontal)
|
||||
.spacing(12)
|
||||
|
||||
@@ -66,7 +66,7 @@ pub fn is_submenu_item(item: &LaunchItem) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use owlry_core::providers::ProviderType;
|
||||
use owlry_core::providers::{ItemSource, ProviderType};
|
||||
|
||||
#[test]
|
||||
fn test_parse_submenu_command() {
|
||||
@@ -94,6 +94,7 @@ mod tests {
|
||||
command: "SUBMENU:plugin:data".to_string(),
|
||||
terminal: false,
|
||||
tags: vec![],
|
||||
source: ItemSource::NativePlugin,
|
||||
};
|
||||
assert!(is_submenu_item(&submenu_item));
|
||||
|
||||
@@ -106,6 +107,7 @@ mod tests {
|
||||
command: "some-command".to_string(),
|
||||
terminal: false,
|
||||
tags: vec![],
|
||||
source: ItemSource::NativePlugin,
|
||||
};
|
||||
assert!(!is_submenu_item(&normal_item));
|
||||
}
|
||||
|
||||
@@ -59,8 +59,10 @@ max_results = 100
|
||||
# Requires: uwsm to be installed
|
||||
# use_uwsm = true
|
||||
|
||||
# Header tabs - providers shown as toggle buttons (Ctrl+1, Ctrl+2, etc.)
|
||||
# Values: app, cmd, uuctl, bookmark, calc, clip, dmenu, emoji, file, script, ssh, sys, web
|
||||
# Header tabs — provider tabs shown in the UI bar (Ctrl+1..9 to toggle)
|
||||
# Core values: app, cmd, dmenu
|
||||
# Plugin values: uuctl, calc, clip, emoji, file, script, ssh, sys, web, bm
|
||||
# Any installed plugin's type_id is valid (e.g. "weather", "media")
|
||||
tabs = ["app", "cmd", "uuctl"]
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
@@ -140,55 +142,25 @@ disabled_plugins = []
|
||||
# PROVIDERS
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# Enable/disable providers and configure their settings.
|
||||
# Core providers (applications, commands) are built into the binary.
|
||||
# Plugin providers require their .so to be installed.
|
||||
# Controls built-in providers only. Plugins are enabled/disabled via
|
||||
# [plugins] disabled_plugins or `owlry plugin enable/disable <name>`.
|
||||
|
||||
[providers]
|
||||
# Core providers (always available)
|
||||
# Core providers
|
||||
applications = true # .desktop applications from XDG dirs
|
||||
commands = true # Executables from $PATH
|
||||
|
||||
# Frecency - boost frequently/recently used items
|
||||
# Built-in providers (compiled into owlry-core)
|
||||
calculator = true # Math expressions (= or calc trigger)
|
||||
converter = true # Unit/currency conversion (> trigger)
|
||||
system = true # System actions: shutdown, reboot, lock, etc.
|
||||
|
||||
# Frecency — boost frequently/recently used items
|
||||
# Data stored in: ~/.local/share/owlry/frecency.json
|
||||
frecency = true
|
||||
frecency_weight = 0.3 # 0.0 = disabled, 1.0 = strong boost
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Plugin provider toggles (require corresponding plugin installed)
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
uuctl = true # systemd user units
|
||||
system = true # System commands (shutdown, reboot, etc.)
|
||||
ssh = true # SSH hosts from ~/.ssh/config
|
||||
clipboard = true # Clipboard history (requires cliphist)
|
||||
bookmarks = true # Browser bookmarks
|
||||
emoji = true # Emoji picker
|
||||
scripts = true # Custom scripts from ~/.local/share/owlry/scripts/
|
||||
files = true # File search (requires fd or mlocate)
|
||||
calculator = true # Calculator (= expression)
|
||||
websearch = true # Web search (? query)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Widget providers (displayed at top of results)
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
media = true # MPRIS media player controls
|
||||
weather = false # Weather widget (disabled by default)
|
||||
pomodoro = false # Pomodoro timer (disabled by default)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Provider settings
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Web search engine
|
||||
# Web search engine (used by owlry-plugin-websearch)
|
||||
# Options: google, duckduckgo, bing, startpage, searxng, brave, ecosia
|
||||
# Or custom URL: "https://search.example.com/?q={query}"
|
||||
# Or a custom URL: "https://search.example.com/?q={query}"
|
||||
search_engine = "duckduckgo"
|
||||
|
||||
# Weather settings (when weather = true)
|
||||
# weather_provider = "wttr.in" # Options: wttr.in, openweathermap, open-meteo
|
||||
# weather_location = "Berlin" # City name or coordinates
|
||||
# weather_api_key = "" # Required for openweathermap
|
||||
|
||||
# Pomodoro settings (when pomodoro = true)
|
||||
# pomodoro_work_mins = 25 # Work session duration
|
||||
# pomodoro_break_mins = 5 # Break duration
|
||||
|
||||
@@ -77,8 +77,6 @@
|
||||
.owlry-main {
|
||||
background-color: rgba(5, 5, 5, 0.98);
|
||||
border: 1px solid rgba(38, 38, 38, 0.8);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8),
|
||||
0 0 0 1px rgba(255, 0, 68, 0.1);
|
||||
}
|
||||
|
||||
.owlry-search {
|
||||
|
||||
@@ -24,8 +24,6 @@
|
||||
.owlry-main {
|
||||
background-color: rgba(30, 30, 46, 0.95);
|
||||
border: 1px solid rgba(69, 71, 90, 0.6);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(203, 166, 247, 0.1);
|
||||
}
|
||||
|
||||
.owlry-search {
|
||||
|
||||
@@ -24,8 +24,6 @@
|
||||
.owlry-main {
|
||||
background-color: rgba(40, 42, 54, 0.95);
|
||||
border: 1px solid rgba(98, 114, 164, 0.6);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(189, 147, 249, 0.1);
|
||||
}
|
||||
|
||||
.owlry-search {
|
||||
|
||||
@@ -24,8 +24,6 @@
|
||||
.owlry-main {
|
||||
background-color: rgba(40, 40, 40, 0.95);
|
||||
border: 1px solid rgba(80, 73, 69, 0.6);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(254, 128, 25, 0.1);
|
||||
}
|
||||
|
||||
.owlry-search {
|
||||
|
||||
@@ -24,8 +24,6 @@
|
||||
.owlry-main {
|
||||
background-color: rgba(46, 52, 64, 0.95);
|
||||
border: 1px solid rgba(76, 86, 106, 0.6);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4),
|
||||
0 0 0 1px rgba(136, 192, 208, 0.1);
|
||||
}
|
||||
|
||||
.owlry-search {
|
||||
|
||||
@@ -24,8 +24,6 @@
|
||||
.owlry-main {
|
||||
background-color: rgba(40, 44, 52, 0.95);
|
||||
border: 1px solid rgba(24, 26, 31, 0.6);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(97, 175, 239, 0.1);
|
||||
}
|
||||
|
||||
.owlry-search {
|
||||
|
||||
@@ -33,8 +33,6 @@
|
||||
.owlry-main {
|
||||
background-color: rgba(26, 27, 38, 0.95);
|
||||
border: 1px solid rgba(65, 72, 104, 0.6);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(224, 175, 104, 0.1);
|
||||
}
|
||||
|
||||
.owlry-search {
|
||||
|
||||
@@ -24,8 +24,6 @@
|
||||
.owlry-main {
|
||||
background-color: rgba(25, 23, 36, 0.95);
|
||||
border: 1px solid rgba(38, 35, 58, 0.6);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(196, 167, 231, 0.1);
|
||||
}
|
||||
|
||||
.owlry-search {
|
||||
|
||||
@@ -24,8 +24,6 @@
|
||||
.owlry-main {
|
||||
background-color: rgba(0, 43, 54, 0.95);
|
||||
border: 1px solid rgba(88, 110, 117, 0.6);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(38, 139, 210, 0.1);
|
||||
}
|
||||
|
||||
.owlry-search {
|
||||
|
||||
@@ -24,8 +24,6 @@
|
||||
.owlry-main {
|
||||
background-color: rgba(26, 27, 38, 0.95);
|
||||
border: 1px solid rgba(65, 72, 104, 0.6);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(122, 162, 247, 0.1);
|
||||
}
|
||||
|
||||
.owlry-search {
|
||||
|
||||
967
docs/superpowers/plans/2026-03-26-codebase-hardening.md
Normal file
967
docs/superpowers/plans/2026-03-26-codebase-hardening.md
Normal file
@@ -0,0 +1,967 @@
|
||||
# Codebase Hardening Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Fix 15 soundness, security, robustness, and quality issues across owlry core and owlry-plugins repos.
|
||||
|
||||
**Architecture:** Point fixes organized into 5 severity tiers. Each tier is one commit. Core repo (owlry) tiers 1-3 first, then plugins repo (owlry-plugins) tiers 4-5. No new features, no refactoring beyond what each fix requires.
|
||||
|
||||
**Tech Stack:** Rust 1.90+, abi_stable 0.11, toml 0.8, dirs 5.0
|
||||
|
||||
**Repos:**
|
||||
- Core: `/home/cnachtigall/ssd/git/archive/owlibou/owlry`
|
||||
- Plugins: `/home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Tier 1 — Critical / Soundness (owlry core)
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/owlry-plugin-api/src/lib.rs:297-320`
|
||||
- Modify: `crates/owlry-core/src/server.rs:1-6,91-123,127-215`
|
||||
|
||||
### 1a. Replace `static mut HOST_API` with `OnceLock`
|
||||
|
||||
- [ ] **Step 1: Replace the static mut and init function**
|
||||
|
||||
In `crates/owlry-plugin-api/src/lib.rs`, replace lines 297-320:
|
||||
|
||||
```rust
|
||||
// Old:
|
||||
// static mut HOST_API: Option<&'static HostAPI> = None;
|
||||
//
|
||||
// pub unsafe fn init_host_api(api: &'static HostAPI) {
|
||||
// unsafe {
|
||||
// HOST_API = Some(api);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// pub fn host_api() -> Option<&'static HostAPI> {
|
||||
// unsafe { HOST_API }
|
||||
// }
|
||||
|
||||
// New:
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static HOST_API: OnceLock<&'static HostAPI> = OnceLock::new();
|
||||
|
||||
/// Initialize the host API (called by the host)
|
||||
///
|
||||
/// # Safety
|
||||
/// Must only be called once by the host before any plugins use the API
|
||||
pub unsafe fn init_host_api(api: &'static HostAPI) {
|
||||
let _ = HOST_API.set(api);
|
||||
}
|
||||
|
||||
/// Get the host API
|
||||
///
|
||||
/// Returns None if the host hasn't initialized the API yet
|
||||
pub fn host_api() -> Option<&'static HostAPI> {
|
||||
HOST_API.get().copied()
|
||||
}
|
||||
```
|
||||
|
||||
Note: `init_host_api` keeps its `unsafe` signature for API compatibility even though `OnceLock::set` is safe. The `unsafe` documents the caller contract.
|
||||
|
||||
- [ ] **Step 2: Verify the plugin-api crate compiles**
|
||||
|
||||
Run: `cargo check -p owlry-plugin-api`
|
||||
Expected: success, no warnings about `static mut`
|
||||
|
||||
### 1b. Add IPC message size limit
|
||||
|
||||
- [ ] **Step 3: Add size-limited read loop in server.rs**
|
||||
|
||||
In `crates/owlry-core/src/server.rs`, add the constant near the top of the file (after the imports):
|
||||
|
||||
```rust
|
||||
/// Maximum size of a single IPC request line (1 MB)
|
||||
const MAX_REQUEST_SIZE: usize = 1_048_576;
|
||||
```
|
||||
|
||||
Replace the `handle_client` method (lines 91-123). Change the `for line in reader.lines()` loop to a manual `read_line` loop with size checking:
|
||||
|
||||
```rust
|
||||
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;
|
||||
let mut reader = reader;
|
||||
let mut line = String::new();
|
||||
|
||||
loop {
|
||||
line.clear();
|
||||
let bytes_read = reader.read_line(&mut line)?;
|
||||
if bytes_read == 0 {
|
||||
break; // EOF
|
||||
}
|
||||
|
||||
if line.len() > MAX_REQUEST_SIZE {
|
||||
let resp = Response::Error {
|
||||
message: "request too large".to_string(),
|
||||
};
|
||||
write_response(&mut writer, &resp)?;
|
||||
break; // Drop connection
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
```
|
||||
|
||||
### 1c. Handle mutex poisoning gracefully
|
||||
|
||||
- [ ] **Step 4: Replace all lock().unwrap() in handle_request**
|
||||
|
||||
In `crates/owlry-core/src/server.rs`, in the `handle_request` method, replace every occurrence of `.lock().unwrap()` with `.lock().unwrap_or_else(|e| e.into_inner())`. There are instances in the `Query`, `Launch`, `Providers`, `Refresh`, `Submenu`, and `PluginAction` arms.
|
||||
|
||||
For example, the Query arm changes from:
|
||||
```rust
|
||||
let pm_guard = pm.lock().unwrap();
|
||||
let frecency_guard = frecency.lock().unwrap();
|
||||
```
|
||||
to:
|
||||
```rust
|
||||
let pm_guard = pm.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let frecency_guard = frecency.lock().unwrap_or_else(|e| e.into_inner());
|
||||
```
|
||||
|
||||
Apply this pattern to all `.lock().unwrap()` calls in `handle_request`.
|
||||
|
||||
- [ ] **Step 5: Build and test the core crate**
|
||||
|
||||
Run: `cargo check -p owlry-core && cargo test -p owlry-core`
|
||||
Expected: all checks pass, all existing tests pass
|
||||
|
||||
- [ ] **Step 6: Commit Tier 1**
|
||||
|
||||
```bash
|
||||
cd /home/cnachtigall/ssd/git/archive/owlibou/owlry
|
||||
git add crates/owlry-plugin-api/src/lib.rs crates/owlry-core/src/server.rs
|
||||
git commit -m "fix: soundness — OnceLock for HOST_API, IPC size limits, mutex poisoning recovery"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Tier 2 — Security (owlry core)
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/owlry-core/src/server.rs:1-6,29-36,91-123`
|
||||
- Modify: `crates/owlry-core/src/main.rs:26-32`
|
||||
|
||||
### 2a. Set socket permissions after bind
|
||||
|
||||
- [ ] **Step 1: Add permission setting in Server::bind**
|
||||
|
||||
In `crates/owlry-core/src/server.rs`, add the import at the top:
|
||||
|
||||
```rust
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
```
|
||||
|
||||
In `Server::bind()`, after the `UnixListener::bind(socket_path)?;` line, add:
|
||||
|
||||
```rust
|
||||
std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(0o600))?;
|
||||
```
|
||||
|
||||
### 2b. Log signal handler failure
|
||||
|
||||
- [ ] **Step 2: Replace .ok() with warning log in main.rs**
|
||||
|
||||
In `crates/owlry-core/src/main.rs`, add `use log::warn;` to the imports, then replace lines 26-32:
|
||||
|
||||
```rust
|
||||
// Old:
|
||||
// ctrlc::set_handler(move || {
|
||||
// let _ = std::fs::remove_file(&sock_cleanup);
|
||||
// std::process::exit(0);
|
||||
// })
|
||||
// .ok();
|
||||
|
||||
// New:
|
||||
if let Err(e) = ctrlc::set_handler(move || {
|
||||
let _ = std::fs::remove_file(&sock_cleanup);
|
||||
std::process::exit(0);
|
||||
}) {
|
||||
warn!("Failed to set signal handler: {}", e);
|
||||
}
|
||||
```
|
||||
|
||||
### 2c. Add client read timeout
|
||||
|
||||
- [ ] **Step 3: Set read timeout on accepted connections**
|
||||
|
||||
In `crates/owlry-core/src/server.rs`, add `use std::time::Duration;` to the imports.
|
||||
|
||||
In the `handle_client` method, at the very top (before the `BufReader` creation), add:
|
||||
|
||||
```rust
|
||||
stream.set_read_timeout(Some(Duration::from_secs(30)))?;
|
||||
```
|
||||
|
||||
This means the `stream` passed to `handle_client` needs to be mutable, or we set it on the clone. Since `set_read_timeout` takes `&self` (not `&mut self`), we can call it directly:
|
||||
|
||||
```rust
|
||||
fn handle_client(
|
||||
stream: UnixStream,
|
||||
pm: Arc<...>,
|
||||
frecency: Arc<...>,
|
||||
config: Arc<Config>,
|
||||
) -> io::Result<()> {
|
||||
stream.set_read_timeout(Some(Duration::from_secs(30)))?;
|
||||
let reader = BufReader::new(stream.try_clone()?);
|
||||
// ... rest unchanged
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build and test**
|
||||
|
||||
Run: `cargo check -p owlry-core && cargo test -p owlry-core`
|
||||
Expected: all checks pass, all existing tests pass
|
||||
|
||||
- [ ] **Step 5: Commit Tier 2**
|
||||
|
||||
```bash
|
||||
cd /home/cnachtigall/ssd/git/archive/owlibou/owlry
|
||||
git add crates/owlry-core/src/server.rs crates/owlry-core/src/main.rs
|
||||
git commit -m "fix: security — socket perms 0600, signal handler logging, client read timeout"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Tier 3 — Robustness / Quality (owlry core)
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/owlry-core/src/server.rs:1-6,17-23,53-73,91-215`
|
||||
|
||||
### 3a. Log malformed JSON requests
|
||||
|
||||
- [ ] **Step 1: Add warn! for JSON parse errors**
|
||||
|
||||
In `crates/owlry-core/src/server.rs`, in the `handle_client` method, in the JSON parse error arm, add a warning log before the error response:
|
||||
|
||||
```rust
|
||||
Err(e) => {
|
||||
warn!("Malformed request from client: {}", e);
|
||||
let resp = Response::Error {
|
||||
message: format!("invalid request JSON: {}", e),
|
||||
};
|
||||
write_response(&mut writer, &resp)?;
|
||||
continue;
|
||||
}
|
||||
```
|
||||
|
||||
### 3b. Replace Mutex with RwLock
|
||||
|
||||
- [ ] **Step 2: Change Server struct and imports**
|
||||
|
||||
In `crates/owlry-core/src/server.rs`, change the import from `Mutex` to `RwLock`:
|
||||
|
||||
```rust
|
||||
use std::sync::{Arc, RwLock};
|
||||
```
|
||||
|
||||
Change the `Server` struct fields:
|
||||
|
||||
```rust
|
||||
pub struct Server {
|
||||
listener: UnixListener,
|
||||
socket_path: PathBuf,
|
||||
provider_manager: Arc<RwLock<ProviderManager>>,
|
||||
frecency: Arc<RwLock<FrecencyStore>>,
|
||||
config: Arc<Config>,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update Server::bind**
|
||||
|
||||
In `Server::bind()`, change `Arc::new(Mutex::new(...))` to `Arc::new(RwLock::new(...))`:
|
||||
|
||||
```rust
|
||||
Ok(Self {
|
||||
listener,
|
||||
socket_path: socket_path.to_path_buf(),
|
||||
provider_manager: Arc::new(RwLock::new(provider_manager)),
|
||||
frecency: Arc::new(RwLock::new(frecency)),
|
||||
config: Arc::new(config),
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update handle_client and handle_request signatures**
|
||||
|
||||
Change `handle_client` parameter types:
|
||||
|
||||
```rust
|
||||
fn handle_client(
|
||||
stream: UnixStream,
|
||||
pm: Arc<RwLock<ProviderManager>>,
|
||||
frecency: Arc<RwLock<FrecencyStore>>,
|
||||
config: Arc<Config>,
|
||||
) -> io::Result<()> {
|
||||
```
|
||||
|
||||
Change `handle_request` parameter types:
|
||||
|
||||
```rust
|
||||
fn handle_request(
|
||||
request: &Request,
|
||||
pm: &Arc<RwLock<ProviderManager>>,
|
||||
frecency: &Arc<RwLock<FrecencyStore>>,
|
||||
config: &Arc<Config>,
|
||||
) -> Response {
|
||||
```
|
||||
|
||||
Also update `handle_one_for_testing` if it passes these types through.
|
||||
|
||||
- [ ] **Step 5: Update lock calls per request type**
|
||||
|
||||
In `handle_request`, change each lock call according to the read/write mapping:
|
||||
|
||||
**Query** (read PM, read frecency):
|
||||
```rust
|
||||
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.read().unwrap_or_else(|e| e.into_inner());
|
||||
let frecency_guard = frecency.read().unwrap_or_else(|e| e.into_inner());
|
||||
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(),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Launch** (write frecency):
|
||||
```rust
|
||||
Request::Launch { item_id, provider: _ } => {
|
||||
let mut frecency_guard = frecency.write().unwrap_or_else(|e| e.into_inner());
|
||||
frecency_guard.record_launch(item_id);
|
||||
Response::Ack
|
||||
}
|
||||
```
|
||||
|
||||
**Providers** (read PM):
|
||||
```rust
|
||||
Request::Providers => {
|
||||
let pm_guard = pm.read().unwrap_or_else(|e| e.into_inner());
|
||||
let descs = pm_guard.available_providers();
|
||||
Response::Providers {
|
||||
list: descs.into_iter().map(descriptor_to_desc).collect(),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Refresh** (write PM):
|
||||
```rust
|
||||
Request::Refresh { provider } => {
|
||||
let mut pm_guard = pm.write().unwrap_or_else(|e| e.into_inner());
|
||||
pm_guard.refresh_provider(provider);
|
||||
Response::Ack
|
||||
}
|
||||
```
|
||||
|
||||
**Toggle** (no locks):
|
||||
```rust
|
||||
Request::Toggle => Response::Ack,
|
||||
```
|
||||
|
||||
**Submenu** (read PM):
|
||||
```rust
|
||||
Request::Submenu { plugin_id, data } => {
|
||||
let pm_guard = pm.read().unwrap_or_else(|e| e.into_inner());
|
||||
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),
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**PluginAction** (read PM):
|
||||
```rust
|
||||
Request::PluginAction { command } => {
|
||||
let pm_guard = pm.read().unwrap_or_else(|e| e.into_inner());
|
||||
if pm_guard.execute_plugin_action(command) {
|
||||
Response::Ack
|
||||
} else {
|
||||
Response::Error {
|
||||
message: format!("no plugin handled action '{}'", command),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Build and test**
|
||||
|
||||
Run: `cargo check -p owlry-core && cargo test -p owlry-core`
|
||||
Expected: all checks pass, all existing tests pass
|
||||
|
||||
- [ ] **Step 7: Commit Tier 3**
|
||||
|
||||
```bash
|
||||
cd /home/cnachtigall/ssd/git/archive/owlibou/owlry
|
||||
git add crates/owlry-core/src/server.rs
|
||||
git commit -m "fix: robustness — RwLock for concurrent reads, log malformed JSON requests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Tier 4 — Critical fixes (owlry-plugins)
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/owlry-plugin-converter/src/currency.rs:88-113,244-265`
|
||||
- Modify: `crates/owlry-plugin-converter/src/units.rs:90-101,160-213`
|
||||
- Modify: `crates/owlry-plugin-bookmarks/src/lib.rs:40-45,228-260,317-353`
|
||||
|
||||
All paths relative to `/home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins`.
|
||||
|
||||
### 4a. Fix Box::leak memory leak in converter
|
||||
|
||||
- [ ] **Step 1: Change resolve_currency_code return type**
|
||||
|
||||
In `crates/owlry-plugin-converter/src/currency.rs`, change the `resolve_currency_code` function (line 88) from returning `Option<String>` to `Option<&'static str>`:
|
||||
|
||||
```rust
|
||||
pub fn resolve_currency_code(alias: &str) -> Option<&'static str> {
|
||||
let lower = alias.to_lowercase();
|
||||
|
||||
// Check aliases
|
||||
for ca in CURRENCY_ALIASES {
|
||||
if ca.aliases.contains(&lower.as_str()) {
|
||||
return Some(ca.code);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a raw 3-letter ISO code we know about
|
||||
let upper = alias.to_uppercase();
|
||||
if upper.len() == 3 {
|
||||
if upper == "EUR" {
|
||||
return Some("EUR");
|
||||
}
|
||||
// Check if we have rates for it — return the matching alias code
|
||||
if let Some(rates) = get_rates()
|
||||
&& rates.rates.contains_key(&upper)
|
||||
{
|
||||
// Find a matching CURRENCY_ALIASES entry for this code
|
||||
for ca in CURRENCY_ALIASES {
|
||||
if ca.code == upper {
|
||||
return Some(ca.code);
|
||||
}
|
||||
}
|
||||
// Not in our aliases but valid in ECB rates — we can't return
|
||||
// a &'static str for an arbitrary code, so skip
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
```
|
||||
|
||||
Note: For ISO codes that are in ECB rates but NOT in `CURRENCY_ALIASES`, we lose the ability to resolve them. This is acceptable because:
|
||||
1. `CURRENCY_ALIASES` already covers the 15 most common currencies
|
||||
2. The alternative (Box::leak) was leaking memory on every keystroke
|
||||
|
||||
- [ ] **Step 2: Update is_currency_alias**
|
||||
|
||||
No change needed — it already just calls `resolve_currency_code(alias).is_some()`.
|
||||
|
||||
- [ ] **Step 3: Update find_unit in units.rs**
|
||||
|
||||
In `crates/owlry-plugin-converter/src/units.rs`, replace lines 90-101:
|
||||
|
||||
```rust
|
||||
pub fn find_unit(alias: &str) -> Option<&'static str> {
|
||||
let lower = alias.to_lowercase();
|
||||
if let Some(&i) = ALIAS_MAP.get(&lower) {
|
||||
return Some(UNITS[i].symbol);
|
||||
}
|
||||
// Check currency — resolve_currency_code now returns &'static str directly
|
||||
currency::resolve_currency_code(&lower)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update convert_currency in units.rs**
|
||||
|
||||
In `crates/owlry-plugin-converter/src/units.rs`, update `convert_currency` (line 160). The `from_code` and `to_code` are now `&'static str`. HashMap lookups with `rates.rates.get(code)` work because `HashMap<String, f64>::get` accepts `&str` via `Borrow`:
|
||||
|
||||
```rust
|
||||
fn convert_currency(value: f64, from: &str, to: &str) -> Option<ConversionResult> {
|
||||
let rates = currency::get_rates()?;
|
||||
let from_code = currency::resolve_currency_code(from)?;
|
||||
let to_code = currency::resolve_currency_code(to)?;
|
||||
|
||||
let from_rate = if from_code == "EUR" {
|
||||
1.0
|
||||
} else {
|
||||
*rates.rates.get(from_code)?
|
||||
};
|
||||
let to_rate = if to_code == "EUR" {
|
||||
1.0
|
||||
} else {
|
||||
*rates.rates.get(to_code)?
|
||||
};
|
||||
|
||||
let result = value / from_rate * to_rate;
|
||||
Some(format_currency_result(result, to_code))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update convert_currency_common in units.rs**
|
||||
|
||||
In `crates/owlry-plugin-converter/src/units.rs`, update `convert_currency_common` (line 180). Change `from_code` handling:
|
||||
|
||||
```rust
|
||||
fn convert_currency_common(value: f64, from: &str) -> Vec<ConversionResult> {
|
||||
let rates = match currency::get_rates() {
|
||||
Some(r) => r,
|
||||
None => return vec![],
|
||||
};
|
||||
let from_code = match currency::resolve_currency_code(from) {
|
||||
Some(c) => c,
|
||||
None => return vec![],
|
||||
};
|
||||
|
||||
let targets = COMMON_TARGETS.get(&Category::Currency).unwrap();
|
||||
let from_rate = if from_code == "EUR" {
|
||||
1.0
|
||||
} else {
|
||||
match rates.rates.get(from_code) {
|
||||
Some(&r) => r,
|
||||
None => return vec![],
|
||||
}
|
||||
};
|
||||
|
||||
targets
|
||||
.iter()
|
||||
.filter(|&&sym| sym != from_code)
|
||||
.filter_map(|&sym| {
|
||||
let to_rate = if sym == "EUR" {
|
||||
1.0
|
||||
} else {
|
||||
*rates.rates.get(sym)?
|
||||
};
|
||||
let result = value / from_rate * to_rate;
|
||||
Some(format_currency_result(result, sym))
|
||||
})
|
||||
.take(5)
|
||||
.collect()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Update currency tests**
|
||||
|
||||
In `crates/owlry-plugin-converter/src/currency.rs`, update test assertions to use `&str` instead of `String`:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn test_resolve_currency_code_iso() {
|
||||
assert_eq!(resolve_currency_code("usd"), Some("USD"));
|
||||
assert_eq!(resolve_currency_code("EUR"), Some("EUR"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_currency_code_name() {
|
||||
assert_eq!(resolve_currency_code("dollar"), Some("USD"));
|
||||
assert_eq!(resolve_currency_code("euro"), Some("EUR"));
|
||||
assert_eq!(resolve_currency_code("pounds"), Some("GBP"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_currency_code_symbol() {
|
||||
assert_eq!(resolve_currency_code("$"), Some("USD"));
|
||||
assert_eq!(resolve_currency_code("€"), Some("EUR"));
|
||||
assert_eq!(resolve_currency_code("£"), Some("GBP"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_currency_unknown() {
|
||||
assert_eq!(resolve_currency_code("xyz"), None);
|
||||
}
|
||||
```
|
||||
|
||||
### 4b. Fix bookmarks temp file race condition
|
||||
|
||||
- [ ] **Step 7: Use PID-based temp filenames**
|
||||
|
||||
In `crates/owlry-plugin-bookmarks/src/lib.rs`, replace the `read_firefox_bookmarks` method. Change lines 318-319 and the corresponding favicons temp path:
|
||||
|
||||
```rust
|
||||
fn read_firefox_bookmarks(places_path: &PathBuf, items: &mut Vec<PluginItem>) {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let pid = std::process::id();
|
||||
let temp_db = temp_dir.join(format!("owlry_places_{}.sqlite", pid));
|
||||
|
||||
// Copy database to temp location to avoid locking issues
|
||||
if fs::copy(places_path, &temp_db).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Also copy WAL file if it exists
|
||||
let wal_path = places_path.with_extension("sqlite-wal");
|
||||
if wal_path.exists() {
|
||||
let temp_wal = temp_db.with_extension("sqlite-wal");
|
||||
let _ = fs::copy(&wal_path, &temp_wal);
|
||||
}
|
||||
|
||||
// Copy favicons database if available
|
||||
let favicons_path = Self::firefox_favicons_path(places_path);
|
||||
let temp_favicons = temp_dir.join(format!("owlry_favicons_{}.sqlite", pid));
|
||||
if let Some(ref fp) = favicons_path {
|
||||
let _ = fs::copy(fp, &temp_favicons);
|
||||
let fav_wal = fp.with_extension("sqlite-wal");
|
||||
if fav_wal.exists() {
|
||||
let _ = fs::copy(&fav_wal, temp_favicons.with_extension("sqlite-wal"));
|
||||
}
|
||||
}
|
||||
|
||||
let cache_dir = Self::ensure_favicon_cache_dir();
|
||||
|
||||
// Read bookmarks from places.sqlite
|
||||
let bookmarks = Self::fetch_firefox_bookmarks(&temp_db, &temp_favicons, cache_dir.as_ref());
|
||||
|
||||
// Clean up temp files
|
||||
let _ = fs::remove_file(&temp_db);
|
||||
let _ = fs::remove_file(temp_db.with_extension("sqlite-wal"));
|
||||
let _ = fs::remove_file(&temp_favicons);
|
||||
let _ = fs::remove_file(temp_favicons.with_extension("sqlite-wal"));
|
||||
|
||||
// ... rest of method unchanged (the for loop adding items)
|
||||
```
|
||||
|
||||
### 4c. Fix bookmarks background refresh never updating state
|
||||
|
||||
- [ ] **Step 8: Change BookmarksState to use Arc<Mutex<Vec<PluginItem>>>**
|
||||
|
||||
In `crates/owlry-plugin-bookmarks/src/lib.rs`, add `use std::sync::Mutex;` to imports (it's already importing `Arc` and `AtomicBool`).
|
||||
|
||||
Change the struct:
|
||||
|
||||
```rust
|
||||
struct BookmarksState {
|
||||
/// Cached bookmark items (shared with background thread)
|
||||
items: Arc<Mutex<Vec<PluginItem>>>,
|
||||
/// Flag to prevent concurrent background loads
|
||||
loading: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl BookmarksState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
items: Arc::new(Mutex::new(Vec::new())),
|
||||
loading: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Update load_bookmarks to write through Arc<Mutex>**
|
||||
|
||||
Update the `load_bookmarks` method:
|
||||
|
||||
```rust
|
||||
fn load_bookmarks(&self) {
|
||||
// Fast path: load from cache immediately if items are empty
|
||||
{
|
||||
let mut items = self.items.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if items.is_empty() {
|
||||
*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();
|
||||
let items_ref = self.items.clone();
|
||||
thread::spawn(move || {
|
||||
let mut new_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 new_items);
|
||||
}
|
||||
}
|
||||
|
||||
// Load Firefox bookmarks with favicons (synchronous with rusqlite)
|
||||
for path in Self::firefox_places_paths() {
|
||||
Self::read_firefox_bookmarks(&path, &mut new_items);
|
||||
}
|
||||
|
||||
// Save to cache for next startup
|
||||
Self::save_cached_bookmarks(&new_items);
|
||||
|
||||
// Update shared state so next refresh returns fresh data
|
||||
if let Ok(mut items) = items_ref.lock() {
|
||||
*items = new_items;
|
||||
}
|
||||
|
||||
loading.store(false, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Note: `load_bookmarks` now takes `&self` instead of `&mut self`.
|
||||
|
||||
- [ ] **Step 10: Update provider_refresh to read from Arc<Mutex>**
|
||||
|
||||
Update the `provider_refresh` function:
|
||||
|
||||
```rust
|
||||
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 { &*(handle.ptr as *const BookmarksState) };
|
||||
|
||||
// Load bookmarks
|
||||
state.load_bookmarks();
|
||||
|
||||
// Return items
|
||||
let items = state.items.lock().unwrap_or_else(|e| e.into_inner());
|
||||
items.to_vec().into()
|
||||
}
|
||||
```
|
||||
|
||||
Note: Uses `&*` (shared ref) instead of `&mut *` since `load_bookmarks` now takes `&self`.
|
||||
|
||||
- [ ] **Step 11: Build and test plugins**
|
||||
|
||||
Run: `cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins && cargo check && cargo test`
|
||||
Expected: all checks pass, all existing tests pass
|
||||
|
||||
- [ ] **Step 12: Commit Tier 4**
|
||||
|
||||
```bash
|
||||
cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins
|
||||
git add crates/owlry-plugin-converter/src/currency.rs crates/owlry-plugin-converter/src/units.rs crates/owlry-plugin-bookmarks/src/lib.rs
|
||||
git commit -m "fix: critical — eliminate Box::leak in converter, secure temp files, fix background refresh"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Tier 5 — Quality fixes (owlry-plugins)
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/owlry-plugin-ssh/Cargo.toml`
|
||||
- Modify: `crates/owlry-plugin-ssh/src/lib.rs:17-48`
|
||||
- Modify: `crates/owlry-plugin-websearch/Cargo.toml`
|
||||
- Modify: `crates/owlry-plugin-websearch/src/lib.rs:46-76,174-177`
|
||||
- Modify: `crates/owlry-plugin-emoji/src/lib.rs:34-37,463-481`
|
||||
- Modify: `crates/owlry-plugin-calculator/src/lib.rs:139`
|
||||
- Modify: `crates/owlry-plugin-converter/src/lib.rs:95`
|
||||
|
||||
All paths relative to `/home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins`.
|
||||
|
||||
### 5a. SSH plugin: read terminal from config
|
||||
|
||||
- [ ] **Step 1: Add toml dependency to SSH plugin**
|
||||
|
||||
In `crates/owlry-plugin-ssh/Cargo.toml`, add:
|
||||
|
||||
```toml
|
||||
# TOML config parsing
|
||||
toml = "0.8"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add config loading and update SshState::new**
|
||||
|
||||
In `crates/owlry-plugin-ssh/src/lib.rs`, add `use std::fs;` to imports, remove the `DEFAULT_TERMINAL` constant, and update `SshState::new`:
|
||||
|
||||
```rust
|
||||
impl SshState {
|
||||
fn new() -> Self {
|
||||
let terminal = Self::load_terminal_from_config();
|
||||
|
||||
Self {
|
||||
items: Vec::new(),
|
||||
terminal_command: terminal,
|
||||
}
|
||||
}
|
||||
|
||||
fn load_terminal_from_config() -> String {
|
||||
// Try [plugins.ssh] in config.toml
|
||||
let config_path = dirs::config_dir().map(|d| d.join("owlry").join("config.toml"));
|
||||
if let Some(content) = config_path.and_then(|p| fs::read_to_string(p).ok())
|
||||
&& let Ok(toml) = content.parse::<toml::Table>()
|
||||
{
|
||||
if let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table())
|
||||
&& let Some(ssh) = plugins.get("ssh").and_then(|v| v.as_table())
|
||||
&& let Some(terminal) = ssh.get("terminal").and_then(|v| v.as_str())
|
||||
{
|
||||
return terminal.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to $TERMINAL env var
|
||||
if let Ok(terminal) = std::env::var("TERMINAL") {
|
||||
return terminal;
|
||||
}
|
||||
|
||||
// Last resort
|
||||
"xdg-terminal-exec".to_string()
|
||||
}
|
||||
```
|
||||
|
||||
### 5b. WebSearch plugin: read engine from config
|
||||
|
||||
- [ ] **Step 3: Add dependencies to websearch plugin**
|
||||
|
||||
In `crates/owlry-plugin-websearch/Cargo.toml`, add:
|
||||
|
||||
```toml
|
||||
# TOML config parsing
|
||||
toml = "0.8"
|
||||
|
||||
# XDG directories for config
|
||||
dirs = "5.0"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add config loading and update provider_init**
|
||||
|
||||
In `crates/owlry-plugin-websearch/src/lib.rs`, add `use std::fs;` to imports. Add a config loading function and update `provider_init`:
|
||||
|
||||
```rust
|
||||
fn load_engine_from_config() -> String {
|
||||
let config_path = dirs::config_dir().map(|d| d.join("owlry").join("config.toml"));
|
||||
if let Some(content) = config_path.and_then(|p| fs::read_to_string(p).ok())
|
||||
&& let Ok(toml) = content.parse::<toml::Table>()
|
||||
{
|
||||
if let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table())
|
||||
&& let Some(websearch) = plugins.get("websearch").and_then(|v| v.as_table())
|
||||
&& let Some(engine) = websearch.get("engine").and_then(|v| v.as_str())
|
||||
{
|
||||
return engine.to_string();
|
||||
}
|
||||
}
|
||||
DEFAULT_ENGINE.to_string()
|
||||
}
|
||||
|
||||
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
|
||||
let engine = load_engine_from_config();
|
||||
let state = Box::new(WebSearchState::with_engine(&engine));
|
||||
ProviderHandle::from_box(state)
|
||||
}
|
||||
```
|
||||
|
||||
Remove the TODO comment from the old `provider_init`.
|
||||
|
||||
### 5c. Emoji plugin: build items once at init
|
||||
|
||||
- [ ] **Step 5: Move load_emojis to constructor**
|
||||
|
||||
In `crates/owlry-plugin-emoji/src/lib.rs`, change `EmojiState::new` to call `load_emojis`:
|
||||
|
||||
```rust
|
||||
impl EmojiState {
|
||||
fn new() -> Self {
|
||||
let mut state = Self { items: Vec::new() };
|
||||
state.load_emojis();
|
||||
state
|
||||
}
|
||||
```
|
||||
|
||||
Update `provider_refresh` to just return the cached items without reloading:
|
||||
|
||||
```rust
|
||||
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 { &*(handle.ptr as *const EmojiState) };
|
||||
|
||||
// Return cached items (loaded once at init)
|
||||
state.items.to_vec().into()
|
||||
}
|
||||
```
|
||||
|
||||
Note: Uses `&*` (shared ref) since we're only reading.
|
||||
|
||||
### 5d. Calculator/Converter: safer shell commands
|
||||
|
||||
- [ ] **Step 6: Fix calculator command**
|
||||
|
||||
In `crates/owlry-plugin-calculator/src/lib.rs`, in `evaluate_expression` (around line 139), replace:
|
||||
|
||||
```rust
|
||||
// Old:
|
||||
format!("sh -c 'echo -n \"{}\" | wl-copy'", result_str)
|
||||
|
||||
// New:
|
||||
format!("printf '%s' '{}' | wl-copy", result_str.replace('\'', "'\\''"))
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Fix converter command**
|
||||
|
||||
In `crates/owlry-plugin-converter/src/lib.rs`, in `provider_query` (around line 95), replace:
|
||||
|
||||
```rust
|
||||
// Old:
|
||||
format!("sh -c 'echo -n \"{}\" | wl-copy'", r.raw_value)
|
||||
|
||||
// New:
|
||||
format!("printf '%s' '{}' | wl-copy", r.raw_value.replace('\'', "'\\''"))
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Build and test all plugins**
|
||||
|
||||
Run: `cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins && cargo check && cargo test`
|
||||
Expected: all checks pass, all existing tests pass
|
||||
|
||||
- [ ] **Step 9: Commit Tier 5**
|
||||
|
||||
```bash
|
||||
cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins
|
||||
git add crates/owlry-plugin-ssh/Cargo.toml crates/owlry-plugin-ssh/src/lib.rs \
|
||||
crates/owlry-plugin-websearch/Cargo.toml crates/owlry-plugin-websearch/src/lib.rs \
|
||||
crates/owlry-plugin-emoji/src/lib.rs \
|
||||
crates/owlry-plugin-calculator/src/lib.rs \
|
||||
crates/owlry-plugin-converter/src/lib.rs
|
||||
git commit -m "fix: quality — config-based terminal/engine, emoji init perf, safer shell commands"
|
||||
```
|
||||
810
docs/superpowers/plans/2026-03-26-runtime-integration.md
Normal file
810
docs/superpowers/plans/2026-03-26-runtime-integration.md
Normal file
@@ -0,0 +1,810 @@
|
||||
# Script Runtime Integration Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Enable the owlry-core daemon to discover and load Lua/Rune user plugins from `~/.config/owlry/plugins/`, with automatic hot-reload on file changes.
|
||||
|
||||
**Architecture:** Fix ABI mismatches between core and runtimes, wire `LoadedRuntime` into `ProviderManager::new_with_config()`, add filesystem watcher for automatic plugin reload. Runtimes are external `.so` libraries loaded from `/usr/lib/owlry/runtimes/`.
|
||||
|
||||
**Tech Stack:** Rust 1.90+, notify 7, notify-debouncer-mini 0.5, libloading 0.8
|
||||
|
||||
**Repos:**
|
||||
- Core: `/home/cnachtigall/ssd/git/archive/owlibou/owlry`
|
||||
- Plugins (docs only): `/home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Fix Lua RuntimeInfo ABI and vtable init signature
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/owlry-lua/src/lib.rs:42-74,260-279,322-336`
|
||||
- Modify: `crates/owlry-rune/src/lib.rs:42-46,73-84,90-95,97-146,215-229`
|
||||
- Modify: `crates/owlry-core/src/plugins/runtime_loader.rs:55-68,84-146,267-277`
|
||||
|
||||
### 1a. Shrink Lua RuntimeInfo to 2 fields
|
||||
|
||||
- [ ] **Step 1: Update RuntimeInfo struct and runtime_info() in owlry-lua**
|
||||
|
||||
In `crates/owlry-lua/src/lib.rs`:
|
||||
|
||||
Remove the `LUA_RUNTIME_API_VERSION` constant (line 43).
|
||||
|
||||
Replace the `RuntimeInfo` struct (lines 67-74):
|
||||
|
||||
```rust
|
||||
/// Runtime info returned by the runtime
|
||||
#[repr(C)]
|
||||
pub struct RuntimeInfo {
|
||||
pub name: RString,
|
||||
pub version: RString,
|
||||
}
|
||||
```
|
||||
|
||||
Replace `runtime_info()` (lines 260-268):
|
||||
|
||||
```rust
|
||||
extern "C" fn runtime_info() -> RuntimeInfo {
|
||||
RuntimeInfo {
|
||||
name: RString::from("Lua"),
|
||||
version: RString::from(env!("CARGO_PKG_VERSION")),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Remove unused constants `RUNTIME_ID` and `RUNTIME_DESCRIPTION` (lines 37, 40) if no longer referenced.
|
||||
|
||||
### 1b. Add owlry_version parameter to vtable init
|
||||
|
||||
- [ ] **Step 2: Update ScriptRuntimeVTable in core**
|
||||
|
||||
In `crates/owlry-core/src/plugins/runtime_loader.rs`, change the `init` field (line 59):
|
||||
|
||||
```rust
|
||||
pub struct ScriptRuntimeVTable {
|
||||
pub info: extern "C" fn() -> RuntimeInfo,
|
||||
pub init: extern "C" fn(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle,
|
||||
pub providers: extern "C" fn(handle: RuntimeHandle) -> RVec<ScriptProviderInfo>,
|
||||
pub refresh: extern "C" fn(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec<PluginItem>,
|
||||
pub query: extern "C" fn(
|
||||
handle: RuntimeHandle,
|
||||
provider_id: RStr<'_>,
|
||||
query: RStr<'_>,
|
||||
) -> RVec<PluginItem>,
|
||||
pub drop: extern "C" fn(handle: RuntimeHandle),
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update LoadedRuntime to pass version**
|
||||
|
||||
In `crates/owlry-core/src/plugins/runtime_loader.rs`, update `load_lua`, `load_rune`, and `load_from_path` to accept and pass the version:
|
||||
|
||||
```rust
|
||||
impl LoadedRuntime {
|
||||
pub fn load_lua(plugins_dir: &Path, owlry_version: &str) -> PluginResult<Self> {
|
||||
Self::load_from_path(
|
||||
"Lua",
|
||||
&PathBuf::from(SYSTEM_RUNTIMES_DIR).join("liblua.so"),
|
||||
b"owlry_lua_runtime_vtable",
|
||||
plugins_dir,
|
||||
owlry_version,
|
||||
)
|
||||
}
|
||||
|
||||
fn load_from_path(
|
||||
name: &'static str,
|
||||
library_path: &Path,
|
||||
vtable_symbol: &[u8],
|
||||
plugins_dir: &Path,
|
||||
owlry_version: &str,
|
||||
) -> PluginResult<Self> {
|
||||
// ... existing library loading code ...
|
||||
|
||||
// Initialize the runtime with version
|
||||
let plugins_dir_str = plugins_dir.to_string_lossy();
|
||||
let handle = (vtable.init)(
|
||||
RStr::from_str(&plugins_dir_str),
|
||||
RStr::from_str(owlry_version),
|
||||
);
|
||||
|
||||
// ... rest unchanged ...
|
||||
}
|
||||
}
|
||||
|
||||
impl LoadedRuntime {
|
||||
pub fn load_rune(plugins_dir: &Path, owlry_version: &str) -> PluginResult<Self> {
|
||||
Self::load_from_path(
|
||||
"Rune",
|
||||
&PathBuf::from(SYSTEM_RUNTIMES_DIR).join("librune.so"),
|
||||
b"owlry_rune_runtime_vtable",
|
||||
plugins_dir,
|
||||
owlry_version,
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update Lua runtime_init to accept version**
|
||||
|
||||
In `crates/owlry-lua/src/lib.rs`, update `runtime_init` (line 270) and the vtable:
|
||||
|
||||
```rust
|
||||
extern "C" fn runtime_init(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle {
|
||||
let plugins_dir = PathBuf::from(plugins_dir.as_str());
|
||||
let mut state = Box::new(LuaRuntimeState::new(plugins_dir));
|
||||
|
||||
state.discover_and_load(owlry_version.as_str());
|
||||
|
||||
RuntimeHandle::from_box(state)
|
||||
}
|
||||
```
|
||||
|
||||
Update the `LuaRuntimeVTable` struct `init` field to match:
|
||||
```rust
|
||||
pub init: extern "C" fn(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle,
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update Rune runtime_init to accept version**
|
||||
|
||||
In `crates/owlry-rune/src/lib.rs`, update `runtime_init` (line 97) and the vtable:
|
||||
|
||||
```rust
|
||||
extern "C" fn runtime_init(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle {
|
||||
let _ = env_logger::try_init();
|
||||
|
||||
let plugins_dir = PathBuf::from(plugins_dir.as_str());
|
||||
let _version = owlry_version.as_str();
|
||||
log::info!(
|
||||
"Initializing Rune runtime with plugins from: {}",
|
||||
plugins_dir.display()
|
||||
);
|
||||
|
||||
// ... rest unchanged — Rune doesn't currently do version checking ...
|
||||
```
|
||||
|
||||
Update the `RuneRuntimeVTable` struct `init` field:
|
||||
```rust
|
||||
pub init: extern "C" fn(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle,
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Build all three crates**
|
||||
|
||||
Run: `cargo check -p owlry-core && cargo check -p owlry-lua && cargo check -p owlry-rune`
|
||||
Expected: all pass
|
||||
|
||||
- [ ] **Step 7: Run tests**
|
||||
|
||||
Run: `cargo test -p owlry-core && cargo test -p owlry-lua && cargo test -p owlry-rune`
|
||||
Expected: all pass
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/owlry-core/src/plugins/runtime_loader.rs \
|
||||
crates/owlry-lua/src/lib.rs \
|
||||
crates/owlry-rune/src/lib.rs
|
||||
git commit -m "fix: align runtime ABI — shrink Lua RuntimeInfo, pass owlry_version to init"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Change default entry points to `main` and add alias
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/owlry-lua/src/manifest.rs:52-54`
|
||||
- Modify: `crates/owlry-rune/src/manifest.rs:36-38,29`
|
||||
|
||||
- [ ] **Step 1: Update Lua manifest default entry**
|
||||
|
||||
In `crates/owlry-lua/src/manifest.rs`, change `default_entry()` (line 52):
|
||||
|
||||
```rust
|
||||
fn default_entry() -> String {
|
||||
"main.lua".to_string()
|
||||
}
|
||||
```
|
||||
|
||||
Add `serde(alias)` to the `entry` field in `PluginInfo` (line 45):
|
||||
|
||||
```rust
|
||||
#[serde(default = "default_entry", alias = "entry_point")]
|
||||
pub entry: String,
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update Rune manifest default entry**
|
||||
|
||||
In `crates/owlry-rune/src/manifest.rs`, change `default_entry()` (line 36):
|
||||
|
||||
```rust
|
||||
fn default_entry() -> String {
|
||||
"main.rn".to_string()
|
||||
}
|
||||
```
|
||||
|
||||
Add `serde(alias)` to the `entry` field in `PluginInfo` (line 29):
|
||||
|
||||
```rust
|
||||
#[serde(default = "default_entry", alias = "entry_point")]
|
||||
pub entry: String,
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update tests that reference init.lua/init.rn**
|
||||
|
||||
In `crates/owlry-lua/src/manifest.rs` test `test_parse_minimal_manifest`:
|
||||
```rust
|
||||
assert_eq!(manifest.plugin.entry, "main.lua");
|
||||
```
|
||||
|
||||
In `crates/owlry-lua/src/loader.rs` test `create_test_plugin`:
|
||||
```rust
|
||||
fs::write(plugin_dir.join("main.lua"), "-- empty plugin").unwrap();
|
||||
```
|
||||
|
||||
In `crates/owlry-rune/src/manifest.rs` test `test_parse_minimal_manifest`:
|
||||
```rust
|
||||
assert_eq!(manifest.plugin.entry, "main.rn");
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build and test**
|
||||
|
||||
Run: `cargo test -p owlry-lua && cargo test -p owlry-rune`
|
||||
Expected: all pass
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/owlry-lua/src/manifest.rs crates/owlry-lua/src/loader.rs \
|
||||
crates/owlry-rune/src/manifest.rs
|
||||
git commit -m "feat: change default entry points to main.lua/main.rn, add entry_point alias"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Wire runtime loading into ProviderManager
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/owlry-core/src/providers/mod.rs:106-119,173-224`
|
||||
- Modify: `crates/owlry-core/src/plugins/runtime_loader.rs:13` (remove allow dead_code)
|
||||
|
||||
- [ ] **Step 1: Add runtimes field to ProviderManager**
|
||||
|
||||
In `crates/owlry-core/src/providers/mod.rs`, add import and field:
|
||||
|
||||
```rust
|
||||
use crate::plugins::runtime_loader::LoadedRuntime;
|
||||
```
|
||||
|
||||
Add to the `ProviderManager` struct (after `matcher` field):
|
||||
|
||||
```rust
|
||||
pub struct ProviderManager {
|
||||
providers: Vec<Box<dyn Provider>>,
|
||||
static_native_providers: Vec<NativeProvider>,
|
||||
dynamic_providers: Vec<NativeProvider>,
|
||||
widget_providers: Vec<NativeProvider>,
|
||||
matcher: SkimMatcherV2,
|
||||
/// Loaded script runtimes (Lua, Rune) — must stay alive to keep Library handles
|
||||
runtimes: Vec<LoadedRuntime>,
|
||||
/// Type IDs of providers that came from script runtimes (for hot-reload removal)
|
||||
runtime_type_ids: std::collections::HashSet<String>,
|
||||
}
|
||||
```
|
||||
|
||||
Update `ProviderManager::new()` to initialize the new fields:
|
||||
|
||||
```rust
|
||||
let mut manager = Self {
|
||||
providers: core_providers,
|
||||
static_native_providers: Vec::new(),
|
||||
dynamic_providers: Vec::new(),
|
||||
widget_providers: Vec::new(),
|
||||
matcher: SkimMatcherV2::default(),
|
||||
runtimes: Vec::new(),
|
||||
runtime_type_ids: std::collections::HashSet::new(),
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add runtime loading to new_with_config**
|
||||
|
||||
In `ProviderManager::new_with_config()`, after the native plugin loading block (after line 221) and before `Self::new(core_providers, native_providers)` (line 223), add runtime loading:
|
||||
|
||||
```rust
|
||||
// Load script runtimes (Lua, Rune) for user plugins
|
||||
let mut runtime_providers: Vec<Box<dyn Provider>> = Vec::new();
|
||||
let mut runtimes: Vec<LoadedRuntime> = Vec::new();
|
||||
let mut runtime_type_ids = std::collections::HashSet::new();
|
||||
let owlry_version = env!("CARGO_PKG_VERSION");
|
||||
|
||||
if let Some(plugins_dir) = crate::paths::plugins_dir() {
|
||||
// Try Lua runtime
|
||||
match LoadedRuntime::load_lua(&plugins_dir, owlry_version) {
|
||||
Ok(rt) => {
|
||||
info!("Loaded Lua runtime with {} provider(s)", rt.providers().len());
|
||||
for provider in rt.create_providers() {
|
||||
let type_id = format!("{}", provider.provider_type());
|
||||
runtime_type_ids.insert(type_id);
|
||||
runtime_providers.push(provider);
|
||||
}
|
||||
runtimes.push(rt);
|
||||
}
|
||||
Err(e) => {
|
||||
info!("Lua runtime not available: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Try Rune runtime
|
||||
match LoadedRuntime::load_rune(&plugins_dir, owlry_version) {
|
||||
Ok(rt) => {
|
||||
info!("Loaded Rune runtime with {} provider(s)", rt.providers().len());
|
||||
for provider in rt.create_providers() {
|
||||
let type_id = format!("{}", provider.provider_type());
|
||||
runtime_type_ids.insert(type_id);
|
||||
runtime_providers.push(provider);
|
||||
}
|
||||
runtimes.push(rt);
|
||||
}
|
||||
Err(e) => {
|
||||
info!("Rune runtime not available: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut manager = Self::new(core_providers, native_providers);
|
||||
manager.runtimes = runtimes;
|
||||
manager.runtime_type_ids = runtime_type_ids;
|
||||
|
||||
// Add runtime providers to the core providers list
|
||||
for provider in runtime_providers {
|
||||
info!("Registered runtime provider: {}", provider.name());
|
||||
manager.providers.push(provider);
|
||||
}
|
||||
|
||||
// Refresh runtime providers
|
||||
for provider in &mut manager.providers {
|
||||
// Only refresh the ones we just added (runtime providers)
|
||||
// They need an initial refresh to populate items
|
||||
}
|
||||
manager.refresh_all();
|
||||
|
||||
manager
|
||||
```
|
||||
|
||||
Note: This replaces the current `Self::new(core_providers, native_providers)` return. The `refresh_all()` at the end of `new()` will be called, plus we call it again — but that's fine since refresh is idempotent. Actually, `new()` already calls `refresh_all()`, so we should remove the duplicate. Let me adjust:
|
||||
|
||||
The cleaner approach is to construct the manager via `Self::new()` which calls `refresh_all()`, then set the runtime fields and add providers, then call `refresh_all()` once more for the newly added runtime providers. Or better — add runtime providers to `core_providers` before calling `new()`:
|
||||
|
||||
```rust
|
||||
// Merge runtime providers into core providers
|
||||
let mut all_core_providers = core_providers;
|
||||
for provider in runtime_providers {
|
||||
info!("Registered runtime provider: {}", provider.name());
|
||||
all_core_providers.push(provider);
|
||||
}
|
||||
|
||||
let mut manager = Self::new(all_core_providers, native_providers);
|
||||
manager.runtimes = runtimes;
|
||||
manager.runtime_type_ids = runtime_type_ids;
|
||||
manager
|
||||
```
|
||||
|
||||
This way `new()` handles the single `refresh_all()` call.
|
||||
|
||||
- [ ] **Step 3: Remove allow(dead_code) from runtime_loader**
|
||||
|
||||
In `crates/owlry-core/src/plugins/runtime_loader.rs`, remove `#![allow(dead_code)]` (line 13).
|
||||
|
||||
Fix any resulting dead code warnings by removing unused `#[allow(dead_code)]` attributes on individual items that are now actually used, or adding targeted `#[allow(dead_code)]` only on truly unused items.
|
||||
|
||||
- [ ] **Step 4: Build and test**
|
||||
|
||||
Run: `cargo check -p owlry-core && cargo test -p owlry-core`
|
||||
Expected: all pass. May see info logs about runtimes loading (if installed on the build machine).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/owlry-core/src/providers/mod.rs \
|
||||
crates/owlry-core/src/plugins/runtime_loader.rs
|
||||
git commit -m "feat: wire script runtime loading into daemon ProviderManager"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Filesystem watcher for hot-reload
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/owlry-core/src/plugins/watcher.rs`
|
||||
- Modify: `crates/owlry-core/src/plugins/mod.rs:23-28` (add module)
|
||||
- Modify: `crates/owlry-core/src/providers/mod.rs` (add reload method)
|
||||
- Modify: `crates/owlry-core/src/server.rs:59-78` (start watcher)
|
||||
- Modify: `crates/owlry-core/Cargo.toml` (add deps)
|
||||
|
||||
- [ ] **Step 1: Add dependencies**
|
||||
|
||||
In `crates/owlry-core/Cargo.toml`, add to `[dependencies]`:
|
||||
|
||||
```toml
|
||||
# Filesystem watching for plugin hot-reload
|
||||
notify = "7"
|
||||
notify-debouncer-mini = "0.5"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add reload_runtimes method to ProviderManager**
|
||||
|
||||
In `crates/owlry-core/src/providers/mod.rs`, add a method:
|
||||
|
||||
```rust
|
||||
/// Reload all script runtime providers (called by filesystem watcher)
|
||||
pub fn reload_runtimes(&mut self) {
|
||||
// Remove old runtime providers from the core providers list
|
||||
self.providers.retain(|p| {
|
||||
let type_str = format!("{}", p.provider_type());
|
||||
!self.runtime_type_ids.contains(&type_str)
|
||||
});
|
||||
|
||||
// Drop old runtimes
|
||||
self.runtimes.clear();
|
||||
self.runtime_type_ids.clear();
|
||||
|
||||
let owlry_version = env!("CARGO_PKG_VERSION");
|
||||
let plugins_dir = match crate::paths::plugins_dir() {
|
||||
Some(d) => d,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Reload Lua runtime
|
||||
match LoadedRuntime::load_lua(&plugins_dir, owlry_version) {
|
||||
Ok(rt) => {
|
||||
info!("Reloaded Lua runtime with {} provider(s)", rt.providers().len());
|
||||
for provider in rt.create_providers() {
|
||||
let type_id = format!("{}", provider.provider_type());
|
||||
self.runtime_type_ids.insert(type_id);
|
||||
self.providers.push(provider);
|
||||
}
|
||||
self.runtimes.push(rt);
|
||||
}
|
||||
Err(e) => {
|
||||
info!("Lua runtime not available on reload: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Reload Rune runtime
|
||||
match LoadedRuntime::load_rune(&plugins_dir, owlry_version) {
|
||||
Ok(rt) => {
|
||||
info!("Reloaded Rune runtime with {} provider(s)", rt.providers().len());
|
||||
for provider in rt.create_providers() {
|
||||
let type_id = format!("{}", provider.provider_type());
|
||||
self.runtime_type_ids.insert(type_id);
|
||||
self.providers.push(provider);
|
||||
}
|
||||
self.runtimes.push(rt);
|
||||
}
|
||||
Err(e) => {
|
||||
info!("Rune runtime not available on reload: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh the newly added providers
|
||||
for provider in &mut self.providers {
|
||||
provider.refresh();
|
||||
}
|
||||
|
||||
info!("Runtime reload complete");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create the watcher module**
|
||||
|
||||
Create `crates/owlry-core/src/plugins/watcher.rs`:
|
||||
|
||||
```rust
|
||||
//! Filesystem watcher for user plugin hot-reload
|
||||
//!
|
||||
//! Watches `~/.config/owlry/plugins/` for changes and triggers
|
||||
//! runtime reload when plugin files are modified.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use log::{info, warn};
|
||||
use notify_debouncer_mini::{DebouncedEventKind, new_debouncer};
|
||||
|
||||
use crate::providers::ProviderManager;
|
||||
|
||||
/// Start watching the user plugins directory for changes.
|
||||
///
|
||||
/// Spawns a background thread that monitors the directory and triggers
|
||||
/// a full runtime reload on any file change. Returns immediately.
|
||||
///
|
||||
/// If the plugins directory doesn't exist or the watcher fails to start,
|
||||
/// logs a warning and returns without spawning a thread.
|
||||
pub fn start_watching(pm: Arc<RwLock<ProviderManager>>) {
|
||||
let plugins_dir = match crate::paths::plugins_dir() {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
info!("No plugins directory configured, skipping file watcher");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if !plugins_dir.exists() {
|
||||
// Create the directory so the watcher has something to watch
|
||||
if std::fs::create_dir_all(&plugins_dir).is_err() {
|
||||
warn!("Failed to create plugins directory: {}", plugins_dir.display());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
thread::spawn(move || {
|
||||
if let Err(e) = watch_loop(&plugins_dir, &pm) {
|
||||
warn!("Plugin watcher stopped: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
info!("Plugin file watcher started for {}", plugins_dir.display());
|
||||
}
|
||||
|
||||
fn watch_loop(
|
||||
plugins_dir: &PathBuf,
|
||||
pm: &Arc<RwLock<ProviderManager>>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
|
||||
let mut debouncer = new_debouncer(Duration::from_millis(500), tx)?;
|
||||
|
||||
debouncer
|
||||
.watcher()
|
||||
.watch(plugins_dir.as_ref(), notify::RecursiveMode::Recursive)?;
|
||||
|
||||
info!("Watching {} for plugin changes", plugins_dir.display());
|
||||
|
||||
loop {
|
||||
match rx.recv() {
|
||||
Ok(Ok(events)) => {
|
||||
// Check if any event is relevant (not just access/metadata)
|
||||
let has_relevant_change = events.iter().any(|e| {
|
||||
matches!(e.kind, DebouncedEventKind::Any | DebouncedEventKind::AnyContinuous)
|
||||
});
|
||||
|
||||
if has_relevant_change {
|
||||
info!("Plugin file change detected, reloading runtimes...");
|
||||
let mut pm_guard = pm.write().unwrap_or_else(|e| e.into_inner());
|
||||
pm_guard.reload_runtimes();
|
||||
}
|
||||
}
|
||||
Ok(Err(errors)) => {
|
||||
for e in errors {
|
||||
warn!("File watcher error: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Channel closed — watcher was dropped
|
||||
return Err(Box::new(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Register the watcher module**
|
||||
|
||||
In `crates/owlry-core/src/plugins/mod.rs`, add after line 28 (`pub mod runtime_loader;`):
|
||||
|
||||
```rust
|
||||
pub mod watcher;
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Start watcher in Server::run**
|
||||
|
||||
In `crates/owlry-core/src/server.rs`, in the `run()` method, before the accept loop, add:
|
||||
|
||||
```rust
|
||||
pub fn run(&self) -> io::Result<()> {
|
||||
// Start filesystem watcher for user plugin hot-reload
|
||||
crate::plugins::watcher::start_watching(Arc::clone(&self.provider_manager));
|
||||
|
||||
info!("Server entering accept loop");
|
||||
for stream in self.listener.incoming() {
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Build and test**
|
||||
|
||||
Run: `cargo check -p owlry-core && cargo test -p owlry-core`
|
||||
Expected: all pass
|
||||
|
||||
- [ ] **Step 7: Manual smoke test**
|
||||
|
||||
```bash
|
||||
# Start the daemon
|
||||
RUST_LOG=info cargo run -p owlry-core
|
||||
|
||||
# In another terminal, create a test plugin
|
||||
mkdir -p ~/.config/owlry/plugins/hotreload-test
|
||||
cat > ~/.config/owlry/plugins/hotreload-test/plugin.toml << 'EOF'
|
||||
[plugin]
|
||||
id = "hotreload-test"
|
||||
name = "Hot Reload Test"
|
||||
version = "0.1.0"
|
||||
EOF
|
||||
cat > ~/.config/owlry/plugins/hotreload-test/main.lua << 'EOF'
|
||||
owlry.provider.register({
|
||||
name = "hotreload-test",
|
||||
refresh = function()
|
||||
return {{ id = "hr1", name = "Hot Reload Works!", command = "echo yes" }}
|
||||
end,
|
||||
})
|
||||
EOF
|
||||
|
||||
# Watch daemon logs — should see "Plugin file change detected, reloading runtimes..."
|
||||
# Clean up after testing
|
||||
rm -rf ~/.config/owlry/plugins/hotreload-test
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/owlry-core/Cargo.toml \
|
||||
crates/owlry-core/src/plugins/watcher.rs \
|
||||
crates/owlry-core/src/plugins/mod.rs \
|
||||
crates/owlry-core/src/providers/mod.rs \
|
||||
crates/owlry-core/src/server.rs
|
||||
git commit -m "feat: add filesystem watcher for automatic user plugin hot-reload"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Update plugin development documentation
|
||||
|
||||
**Files:**
|
||||
- Modify: `/home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins/docs/PLUGIN_DEVELOPMENT.md`
|
||||
|
||||
- [ ] **Step 1: Update Lua plugin section**
|
||||
|
||||
In `docs/PLUGIN_DEVELOPMENT.md`, update the Lua Quick Start section (around line 101):
|
||||
|
||||
Change `entry_point = "init.lua"` to `entry = "main.lua"` in the manifest example.
|
||||
|
||||
Replace the Lua code example with the `owlry.provider.register()` API:
|
||||
|
||||
```lua
|
||||
owlry.provider.register({
|
||||
name = "myluaprovider",
|
||||
display_name = "My Lua Provider",
|
||||
type_id = "mylua",
|
||||
default_icon = "application-x-executable",
|
||||
prefix = ":mylua",
|
||||
refresh = function()
|
||||
return {
|
||||
{ id = "item-1", name = "Hello from Lua", command = "echo 'Hello Lua!'" },
|
||||
}
|
||||
end,
|
||||
})
|
||||
```
|
||||
|
||||
Remove `local owlry = require("owlry")` — the `owlry` table is pre-registered globally.
|
||||
|
||||
- [ ] **Step 2: Update Rune plugin section**
|
||||
|
||||
Update the Rune manifest example to use `entry = "main.rn"` instead of `entry_point = "main.rn"`.
|
||||
|
||||
- [ ] **Step 3: Update manifest reference**
|
||||
|
||||
In the Lua Plugin API manifest section (around line 325), change `entry_point` to `entry` and add a note:
|
||||
|
||||
```toml
|
||||
[plugin]
|
||||
id = "my-plugin"
|
||||
name = "My Plugin"
|
||||
version = "1.0.0"
|
||||
description = "Plugin description"
|
||||
entry = "main.lua" # Default: main.lua (Lua) / main.rn (Rune)
|
||||
# Alias: entry_point also accepted
|
||||
owlry_version = ">=1.0.0" # Optional version constraint
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add hot-reload documentation**
|
||||
|
||||
Add a new section after "Best Practices" (before "Publishing to AUR"):
|
||||
|
||||
```markdown
|
||||
## Hot Reload
|
||||
|
||||
User plugins in `~/.config/owlry/plugins/` are automatically reloaded when files change.
|
||||
The daemon watches the plugins directory and reloads all script runtimes when any file
|
||||
is created, modified, or deleted. No daemon restart is needed.
|
||||
|
||||
**What triggers a reload:**
|
||||
- Creating a new plugin directory with `plugin.toml`
|
||||
- Editing a plugin's script files (`main.lua`, `main.rn`, etc.)
|
||||
- Editing a plugin's `plugin.toml`
|
||||
- Deleting a plugin directory
|
||||
|
||||
**What does NOT trigger a reload:**
|
||||
- Changes to native plugins (`.so` files) — these require a daemon restart
|
||||
- Changes to runtime libraries in `/usr/lib/owlry/runtimes/` — daemon restart needed
|
||||
|
||||
**Reload behavior:**
|
||||
- All script runtimes (Lua, Rune) are fully reloaded
|
||||
- Existing search results may briefly show stale data during reload
|
||||
- Errors in plugins are logged but don't affect other plugins
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update Lua provider functions section**
|
||||
|
||||
Replace the bare `refresh()`/`query()` examples (around line 390) with the register API:
|
||||
|
||||
```lua
|
||||
-- Static provider: called once at startup and on reload
|
||||
owlry.provider.register({
|
||||
name = "my-provider",
|
||||
display_name = "My Provider",
|
||||
prefix = ":my",
|
||||
refresh = function()
|
||||
return {
|
||||
{ id = "id1", name = "Item 1", command = "command1" },
|
||||
{ id = "id2", name = "Item 2", command = "command2" },
|
||||
}
|
||||
end,
|
||||
})
|
||||
|
||||
-- Dynamic provider: called on each keystroke
|
||||
owlry.provider.register({
|
||||
name = "my-search",
|
||||
display_name = "My Search",
|
||||
prefix = "?my",
|
||||
query = function(q)
|
||||
if q == "" then return {} end
|
||||
return {
|
||||
{ id = "result", name = "Result for: " .. q, command = "echo " .. q },
|
||||
}
|
||||
end,
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins
|
||||
git add docs/PLUGIN_DEVELOPMENT.md
|
||||
git commit -m "docs: update plugin development guide for main.lua/rn defaults, register API, hot-reload"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Update hello-test plugin and clean up
|
||||
|
||||
**Files:**
|
||||
- Modify: `~/.config/owlry/plugins/hello-test/plugin.toml`
|
||||
- Modify: `~/.config/owlry/plugins/hello-test/init.lua` → rename to `main.lua`
|
||||
|
||||
This is a local-only task, not committed to either repo.
|
||||
|
||||
- [ ] **Step 1: Update hello-test plugin**
|
||||
|
||||
```bash
|
||||
# Rename entry point
|
||||
mv ~/.config/owlry/plugins/hello-test/init.lua ~/.config/owlry/plugins/hello-test/main.lua
|
||||
|
||||
# Update manifest to use entry field
|
||||
cat > ~/.config/owlry/plugins/hello-test/plugin.toml << 'EOF'
|
||||
[plugin]
|
||||
id = "hello-test"
|
||||
name = "Hello Test"
|
||||
version = "0.1.0"
|
||||
description = "Minimal test plugin for verifying Lua runtime loading"
|
||||
EOF
|
||||
```
|
||||
|
||||
- [ ] **Step 2: End-to-end verification**
|
||||
|
||||
```bash
|
||||
# Rebuild and restart daemon
|
||||
cargo build -p owlry-core
|
||||
RUST_LOG=info cargo run -p owlry-core
|
||||
|
||||
# Expected log output should include:
|
||||
# - "Loaded Lua runtime with 1 provider(s)" (hello-test)
|
||||
# - "Loaded Rune runtime with 1 provider(s)" (hyprshutdown)
|
||||
# - "Plugin file watcher started for ..."
|
||||
```
|
||||
1225
docs/superpowers/plans/2026-03-28-builtin-providers.md
Normal file
1225
docs/superpowers/plans/2026-03-28-builtin-providers.md
Normal file
File diff suppressed because it is too large
Load Diff
876
docs/superpowers/plans/2026-03-28-config-editor.md
Normal file
876
docs/superpowers/plans/2026-03-28-config-editor.md
Normal file
@@ -0,0 +1,876 @@
|
||||
# Config Editor Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Built-in `:config` provider that lets users browse and modify settings, toggle providers, select themes/engines, and manage profiles — all from within the launcher.
|
||||
|
||||
**Architecture:** The config editor is a `DynamicProvider` that interprets the query text as a navigation path. `:config providers` shows toggles, `:config theme` lists themes, `:config profile dev modes` shows a mode checklist. Actions (toggling, setting values) use the existing `PluginAction` IPC flow which keeps the window open and re-queries, giving instant visual feedback. Config changes are persisted to `config.toml` via `Config::save()`.
|
||||
|
||||
**Tech Stack:** Rust, owlry-core providers, toml serialization
|
||||
|
||||
---
|
||||
|
||||
## Key Design Decision: Query-as-Navigation
|
||||
|
||||
Instead of submenus, the `:config` prefix scopes the search bar as navigation:
|
||||
|
||||
```
|
||||
:config → category list
|
||||
:config providers → provider toggles
|
||||
:config theme → theme selection
|
||||
:config engine → search engine selection
|
||||
:config frecency → frecency toggle + weight
|
||||
:config profiles → profile list
|
||||
:config profile dev → profile actions (edit modes, rename, delete)
|
||||
:config profile dev modes → mode checklist for profile
|
||||
:config profile create myname → create profile action
|
||||
:config fontsize 16 → set font size action
|
||||
:config width 900 → set width action
|
||||
```
|
||||
|
||||
Actions use `CONFIG:*` commands dispatched via `execute_plugin_action`. Since this returns `false` for `should_close`, the window stays open and re-queries — the user sees updated state immediately.
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|------|--------|----------------|
|
||||
| `crates/owlry-core/src/providers/config_editor.rs` | Create | ConfigProvider: query parsing, result generation, action execution |
|
||||
| `crates/owlry-core/src/providers/mod.rs` | Modify | Register ConfigProvider, extend action dispatch |
|
||||
| `crates/owlry-core/src/config/mod.rs` | Modify | Add helper methods for config mutation |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create ConfigProvider skeleton and register it
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/owlry-core/src/providers/config_editor.rs`
|
||||
- Modify: `crates/owlry-core/src/providers/mod.rs`
|
||||
|
||||
- [ ] **Step 1: Add module declaration**
|
||||
|
||||
In `crates/owlry-core/src/providers/mod.rs`, add with the other module declarations:
|
||||
|
||||
```rust
|
||||
pub(crate) mod config_editor;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create config_editor.rs with top-level categories**
|
||||
|
||||
Create `crates/owlry-core/src/providers/config_editor.rs`:
|
||||
|
||||
```rust
|
||||
//! Built-in config editor provider.
|
||||
//!
|
||||
//! Lets users browse and modify settings from within the launcher.
|
||||
//! Uses `:config` prefix with query-as-navigation pattern.
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use crate::config::Config;
|
||||
use super::{DynamicProvider, LaunchItem, ProviderType};
|
||||
|
||||
const PROVIDER_TYPE_ID: &str = "config";
|
||||
const PROVIDER_ICON: &str = "preferences-system-symbolic";
|
||||
|
||||
pub struct ConfigProvider {
|
||||
config: Arc<RwLock<Config>>,
|
||||
}
|
||||
|
||||
impl ConfigProvider {
|
||||
pub fn new(config: Arc<RwLock<Config>>) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
/// Execute a CONFIG:* action command. Returns true if handled.
|
||||
pub fn execute_action(&self, command: &str) -> bool {
|
||||
let Some(action) = command.strip_prefix("CONFIG:") else {
|
||||
return false;
|
||||
};
|
||||
let mut config = match self.config.write() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
let handled = self.handle_action(action, &mut config);
|
||||
|
||||
if handled {
|
||||
if let Err(e) = config.save() {
|
||||
log::warn!("Failed to save config: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
handled
|
||||
}
|
||||
|
||||
fn handle_action(&self, action: &str, config: &mut Config) -> bool {
|
||||
if let Some(key) = action.strip_prefix("toggle:") {
|
||||
return self.toggle_bool(key, config);
|
||||
}
|
||||
if let Some(rest) = action.strip_prefix("set:") {
|
||||
return self.set_value(rest, config);
|
||||
}
|
||||
if let Some(rest) = action.strip_prefix("profile:") {
|
||||
return self.handle_profile_action(rest, config);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn toggle_bool(&self, key: &str, config: &mut Config) -> bool {
|
||||
match key {
|
||||
"providers.applications" => { config.providers.applications = !config.providers.applications; true }
|
||||
"providers.commands" => { config.providers.commands = !config.providers.commands; true }
|
||||
"providers.calculator" => { config.providers.calculator = !config.providers.calculator; true }
|
||||
"providers.converter" => { config.providers.converter = !config.providers.converter; true }
|
||||
"providers.system" => { config.providers.system = !config.providers.system; true }
|
||||
"providers.websearch" => { config.providers.websearch = !config.providers.websearch; true }
|
||||
"providers.ssh" => { config.providers.ssh = !config.providers.ssh; true }
|
||||
"providers.clipboard" => { config.providers.clipboard = !config.providers.clipboard; true }
|
||||
"providers.bookmarks" => { config.providers.bookmarks = !config.providers.bookmarks; true }
|
||||
"providers.emoji" => { config.providers.emoji = !config.providers.emoji; true }
|
||||
"providers.scripts" => { config.providers.scripts = !config.providers.scripts; true }
|
||||
"providers.files" => { config.providers.files = !config.providers.files; true }
|
||||
"providers.uuctl" => { config.providers.uuctl = !config.providers.uuctl; true }
|
||||
"providers.media" => { config.providers.media = !config.providers.media; true }
|
||||
"providers.weather" => { config.providers.weather = !config.providers.weather; true }
|
||||
"providers.pomodoro" => { config.providers.pomodoro = !config.providers.pomodoro; true }
|
||||
"providers.frecency" => { config.providers.frecency = !config.providers.frecency; true }
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_value(&self, rest: &str, config: &mut Config) -> bool {
|
||||
let Some((key, value)) = rest.split_once(':') else { return false };
|
||||
match key {
|
||||
"appearance.theme" => { config.appearance.theme = Some(value.to_string()); true }
|
||||
"appearance.font_size" => {
|
||||
if let Ok(v) = value.parse::<i32>() {
|
||||
config.appearance.font_size = v;
|
||||
true
|
||||
} else { false }
|
||||
}
|
||||
"appearance.width" => {
|
||||
if let Ok(v) = value.parse::<i32>() {
|
||||
config.appearance.width = v;
|
||||
true
|
||||
} else { false }
|
||||
}
|
||||
"appearance.height" => {
|
||||
if let Ok(v) = value.parse::<i32>() {
|
||||
config.appearance.height = v;
|
||||
true
|
||||
} else { false }
|
||||
}
|
||||
"appearance.border_radius" => {
|
||||
if let Ok(v) = value.parse::<i32>() {
|
||||
config.appearance.border_radius = v;
|
||||
true
|
||||
} else { false }
|
||||
}
|
||||
"providers.search_engine" => { config.providers.search_engine = value.to_string(); true }
|
||||
"providers.frecency_weight" => {
|
||||
if let Ok(v) = value.parse::<f64>() {
|
||||
config.providers.frecency_weight = v.clamp(0.0, 1.0);
|
||||
true
|
||||
} else { false }
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_profile_action(&self, rest: &str, config: &mut Config) -> bool {
|
||||
if let Some(name) = rest.strip_prefix("create:") {
|
||||
config.profiles.entry(name.to_string()).or_insert_with(|| {
|
||||
crate::config::ProfileConfig { modes: vec![] }
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if let Some(name) = rest.strip_prefix("delete:") {
|
||||
config.profiles.remove(name);
|
||||
return true;
|
||||
}
|
||||
if let Some(rest) = rest.strip_prefix("rename:") {
|
||||
if let Some((old, new)) = rest.split_once(':') {
|
||||
if let Some(profile) = config.profiles.remove(old) {
|
||||
config.profiles.insert(new.to_string(), profile);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if let Some(rest) = rest.strip_prefix("mode:") {
|
||||
// format: profile_name:toggle:mode_name
|
||||
let parts: Vec<&str> = rest.splitn(3, ':').collect();
|
||||
if parts.len() == 3 && parts[1] == "toggle" {
|
||||
let profile_name = parts[0];
|
||||
let mode = parts[2];
|
||||
if let Some(profile) = config.profiles.get_mut(profile_name) {
|
||||
if let Some(pos) = profile.modes.iter().position(|m| m == mode) {
|
||||
profile.modes.remove(pos);
|
||||
} else {
|
||||
profile.modes.push(mode.to_string());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl DynamicProvider for ConfigProvider {
|
||||
fn name(&self) -> &str {
|
||||
"Config"
|
||||
}
|
||||
|
||||
fn provider_type(&self) -> ProviderType {
|
||||
ProviderType::Plugin(PROVIDER_TYPE_ID.into())
|
||||
}
|
||||
|
||||
fn query(&self, query: &str) -> Vec<LaunchItem> {
|
||||
let config = match self.config.read() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
let path = query.trim();
|
||||
self.generate_items(path, &config)
|
||||
}
|
||||
|
||||
fn priority(&self) -> u32 {
|
||||
8_000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement generate_items — the query router**
|
||||
|
||||
Add to `ConfigProvider`:
|
||||
|
||||
```rust
|
||||
fn generate_items(&self, path: &str, config: &Config) -> Vec<LaunchItem> {
|
||||
// Top-level categories
|
||||
if path.is_empty() {
|
||||
return self.top_level_items();
|
||||
}
|
||||
|
||||
let (section, rest) = match path.split_once(' ') {
|
||||
Some((s, r)) => (s, r.trim()),
|
||||
None => (path, ""),
|
||||
};
|
||||
|
||||
match section {
|
||||
"providers" => self.provider_items(config),
|
||||
"theme" => self.theme_items(config, rest),
|
||||
"engine" => self.engine_items(config),
|
||||
"frecency" => self.frecency_items(config, rest),
|
||||
"fontsize" => self.numeric_item("Font Size", "appearance.font_size", config.appearance.font_size, rest),
|
||||
"width" => self.numeric_item("Width", "appearance.width", config.appearance.width, rest),
|
||||
"height" => self.numeric_item("Height", "appearance.height", config.appearance.height, rest),
|
||||
"radius" => self.numeric_item("Border Radius", "appearance.border_radius", config.appearance.border_radius, rest),
|
||||
"profiles" => self.profile_items(config, rest),
|
||||
"profile" => self.profile_detail_items(config, rest),
|
||||
_ => self.top_level_items(),
|
||||
}
|
||||
}
|
||||
|
||||
fn top_level_items(&self) -> Vec<LaunchItem> {
|
||||
vec![
|
||||
self.make_item("config:providers", "Providers", "Toggle providers on/off", ""),
|
||||
self.make_item("config:theme", "Theme", "Select color theme", ""),
|
||||
self.make_item("config:engine", "Search Engine", "Select web search engine", ""),
|
||||
self.make_item("config:frecency", "Frecency", "Frecency ranking settings", ""),
|
||||
self.make_item("config:fontsize", "Font Size", "Set UI font size", ""),
|
||||
self.make_item("config:width", "Width", "Set window width", ""),
|
||||
self.make_item("config:height", "Height", "Set window height", ""),
|
||||
self.make_item("config:radius", "Border Radius", "Set border radius", ""),
|
||||
self.make_item("config:profiles", "Profiles", "Manage named mode profiles", ""),
|
||||
]
|
||||
}
|
||||
|
||||
fn make_item(&self, id: &str, name: &str, description: &str, command: &str) -> LaunchItem {
|
||||
LaunchItem {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
description: Some(description.to_string()),
|
||||
icon: Some(PROVIDER_ICON.into()),
|
||||
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
|
||||
command: command.to_string(),
|
||||
terminal: false,
|
||||
tags: vec!["config".into(), "settings".into()],
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_item(&self, id: &str, name: &str, enabled: bool, key: &str) -> LaunchItem {
|
||||
let prefix = if enabled { "✓" } else { "✗" };
|
||||
LaunchItem {
|
||||
id: id.to_string(),
|
||||
name: format!("{} {}", prefix, name),
|
||||
description: Some(format!("{} (click to toggle)", if enabled { "Enabled" } else { "Disabled" })),
|
||||
icon: Some(PROVIDER_ICON.into()),
|
||||
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
|
||||
command: format!("CONFIG:toggle:{}", key),
|
||||
terminal: false,
|
||||
tags: vec!["config".into()],
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Implement provider_items**
|
||||
|
||||
```rust
|
||||
fn provider_items(&self, config: &Config) -> Vec<LaunchItem> {
|
||||
vec![
|
||||
self.toggle_item("config:prov:app", "Applications", config.providers.applications, "providers.applications"),
|
||||
self.toggle_item("config:prov:cmd", "Commands", config.providers.commands, "providers.commands"),
|
||||
self.toggle_item("config:prov:calc", "Calculator", config.providers.calculator, "providers.calculator"),
|
||||
self.toggle_item("config:prov:conv", "Converter", config.providers.converter, "providers.converter"),
|
||||
self.toggle_item("config:prov:sys", "System", config.providers.system, "providers.system"),
|
||||
self.toggle_item("config:prov:web", "Web Search", config.providers.websearch, "providers.websearch"),
|
||||
self.toggle_item("config:prov:ssh", "SSH", config.providers.ssh, "providers.ssh"),
|
||||
self.toggle_item("config:prov:clip", "Clipboard", config.providers.clipboard, "providers.clipboard"),
|
||||
self.toggle_item("config:prov:bm", "Bookmarks", config.providers.bookmarks, "providers.bookmarks"),
|
||||
self.toggle_item("config:prov:emoji", "Emoji", config.providers.emoji, "providers.emoji"),
|
||||
self.toggle_item("config:prov:scripts", "Scripts", config.providers.scripts, "providers.scripts"),
|
||||
self.toggle_item("config:prov:files", "File Search", config.providers.files, "providers.files"),
|
||||
self.toggle_item("config:prov:uuctl", "systemd Units", config.providers.uuctl, "providers.uuctl"),
|
||||
self.toggle_item("config:prov:media", "Media", config.providers.media, "providers.media"),
|
||||
self.toggle_item("config:prov:weather", "Weather", config.providers.weather, "providers.weather"),
|
||||
self.toggle_item("config:prov:pomo", "Pomodoro", config.providers.pomodoro, "providers.pomodoro"),
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Implement theme_items and engine_items**
|
||||
|
||||
```rust
|
||||
fn theme_items(&self, config: &Config, filter: &str) -> Vec<LaunchItem> {
|
||||
let current = config.appearance.theme.as_deref().unwrap_or("(default)");
|
||||
let themes = [
|
||||
"owl", "catppuccin-mocha", "nord", "rose-pine", "dracula",
|
||||
"gruvbox-dark", "tokyo-night", "solarized-dark", "one-dark", "apex-neon",
|
||||
];
|
||||
|
||||
themes.iter()
|
||||
.filter(|t| filter.is_empty() || t.contains(filter))
|
||||
.map(|t| {
|
||||
let mark = if *t == current { "● " } else { " " };
|
||||
LaunchItem {
|
||||
id: format!("config:theme:{}", t),
|
||||
name: format!("{}{}", mark, t),
|
||||
description: Some(if *t == current { "Current theme".into() } else { "Select this theme".into() }),
|
||||
icon: Some(PROVIDER_ICON.into()),
|
||||
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
|
||||
command: format!("CONFIG:set:appearance.theme:{}", t),
|
||||
terminal: false,
|
||||
tags: vec!["config".into()],
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn engine_items(&self, config: &Config) -> Vec<LaunchItem> {
|
||||
let current = &config.providers.search_engine;
|
||||
let engines = [
|
||||
"duckduckgo", "google", "bing", "startpage", "brave", "ecosia",
|
||||
];
|
||||
|
||||
engines.iter()
|
||||
.map(|e| {
|
||||
let mark = if *e == current.as_str() { "● " } else { " " };
|
||||
LaunchItem {
|
||||
id: format!("config:engine:{}", e),
|
||||
name: format!("{}{}", mark, e),
|
||||
description: Some(if *e == current.as_str() { "Current engine".into() } else { "Select this engine".into() }),
|
||||
icon: Some(PROVIDER_ICON.into()),
|
||||
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
|
||||
command: format!("CONFIG:set:providers.search_engine:{}", e),
|
||||
terminal: false,
|
||||
tags: vec!["config".into()],
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Implement frecency_items and numeric_item**
|
||||
|
||||
```rust
|
||||
fn frecency_items(&self, config: &Config, rest: &str) -> Vec<LaunchItem> {
|
||||
let mut items = vec![
|
||||
self.toggle_item("config:frecency:toggle", "Frecency Ranking", config.providers.frecency, "providers.frecency"),
|
||||
];
|
||||
|
||||
// If user typed a weight value, show a set action
|
||||
if !rest.is_empty() {
|
||||
if let Ok(v) = rest.parse::<f64>() {
|
||||
let clamped = v.clamp(0.0, 1.0);
|
||||
items.push(LaunchItem {
|
||||
id: "config:frecency:set".into(),
|
||||
name: format!("Set weight to {:.1}", clamped),
|
||||
description: Some(format!("Current: {:.1}", config.providers.frecency_weight)),
|
||||
icon: Some(PROVIDER_ICON.into()),
|
||||
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
|
||||
command: format!("CONFIG:set:providers.frecency_weight:{}", clamped),
|
||||
terminal: false,
|
||||
tags: vec!["config".into()],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
items.push(LaunchItem {
|
||||
id: "config:frecency:weight".into(),
|
||||
name: format!("Weight: {:.1}", config.providers.frecency_weight),
|
||||
description: Some("Type a value (0.0–1.0) after :config frecency".into()),
|
||||
icon: Some(PROVIDER_ICON.into()),
|
||||
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
|
||||
command: String::new(),
|
||||
terminal: false,
|
||||
tags: vec!["config".into()],
|
||||
});
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
fn numeric_item(&self, label: &str, key: &str, current: i32, input: &str) -> Vec<LaunchItem> {
|
||||
if !input.is_empty() {
|
||||
if let Ok(v) = input.parse::<i32>() {
|
||||
return vec![LaunchItem {
|
||||
id: format!("config:set:{}", key),
|
||||
name: format!("Set {} to {}", label, v),
|
||||
description: Some(format!("Current: {} (restart to apply)", current)),
|
||||
icon: Some(PROVIDER_ICON.into()),
|
||||
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
|
||||
command: format!("CONFIG:set:{}:{}", key, v),
|
||||
terminal: false,
|
||||
tags: vec!["config".into()],
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
vec![LaunchItem {
|
||||
id: format!("config:show:{}", key),
|
||||
name: format!("{}: {}", label, current),
|
||||
description: Some(format!("Type a number after :config {} to change (restart to apply)", key.rsplit('.').next().unwrap_or(key))),
|
||||
icon: Some(PROVIDER_ICON.into()),
|
||||
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
|
||||
command: String::new(),
|
||||
terminal: false,
|
||||
tags: vec!["config".into()],
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Implement profile_items and profile_detail_items**
|
||||
|
||||
```rust
|
||||
fn profile_items(&self, config: &Config, filter: &str) -> Vec<LaunchItem> {
|
||||
let mut items: Vec<LaunchItem> = config.profiles.iter()
|
||||
.filter(|(name, _)| filter.is_empty() || name.contains(filter))
|
||||
.map(|(name, profile)| {
|
||||
let modes = profile.modes.join(", ");
|
||||
LaunchItem {
|
||||
id: format!("config:profile:{}", name),
|
||||
name: name.clone(),
|
||||
description: Some(if modes.is_empty() { "(no modes)".into() } else { modes }),
|
||||
icon: Some(PROVIDER_ICON.into()),
|
||||
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
|
||||
command: String::new(), // navigate deeper by typing :config profile <name>
|
||||
terminal: false,
|
||||
tags: vec!["config".into(), "profile".into()],
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// "Create" action — user types :config profile create <name>
|
||||
items.push(LaunchItem {
|
||||
id: "config:profile:create_hint".into(),
|
||||
name: "➕ Create New Profile".into(),
|
||||
description: Some("Type: :config profile create <name>".into()),
|
||||
icon: Some(PROVIDER_ICON.into()),
|
||||
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
|
||||
command: String::new(),
|
||||
terminal: false,
|
||||
tags: vec!["config".into()],
|
||||
});
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
fn profile_detail_items(&self, config: &Config, rest: &str) -> Vec<LaunchItem> {
|
||||
let (profile_name, sub) = match rest.split_once(' ') {
|
||||
Some((n, s)) => (n, s.trim()),
|
||||
None => (rest, ""),
|
||||
};
|
||||
|
||||
// Handle "profile create <name>"
|
||||
if profile_name == "create" && !sub.is_empty() {
|
||||
return vec![LaunchItem {
|
||||
id: format!("config:profile:create:{}", sub),
|
||||
name: format!("Create profile '{}'", sub),
|
||||
description: Some("Press Enter to create".into()),
|
||||
icon: Some(PROVIDER_ICON.into()),
|
||||
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
|
||||
command: format!("CONFIG:profile:create:{}", sub),
|
||||
terminal: false,
|
||||
tags: vec!["config".into()],
|
||||
}];
|
||||
}
|
||||
|
||||
let profile = match config.profiles.get(profile_name) {
|
||||
Some(p) => p,
|
||||
None => return vec![],
|
||||
};
|
||||
|
||||
if sub == "modes" || sub.starts_with("modes") {
|
||||
// Mode checklist
|
||||
let all_modes = [
|
||||
"app", "cmd", "calc", "conv", "sys", "web", "ssh", "clip",
|
||||
"bm", "emoji", "scripts", "file", "uuctl", "media", "weather", "pomo",
|
||||
];
|
||||
return all_modes.iter()
|
||||
.map(|mode| {
|
||||
let enabled = profile.modes.iter().any(|m| m == mode);
|
||||
let prefix = if enabled { "✓" } else { "✗" };
|
||||
LaunchItem {
|
||||
id: format!("config:profile:{}:mode:{}", profile_name, mode),
|
||||
name: format!("{} {}", prefix, mode),
|
||||
description: Some(format!("{} in profile '{}'", if enabled { "Enabled" } else { "Disabled" }, profile_name)),
|
||||
icon: Some(PROVIDER_ICON.into()),
|
||||
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
|
||||
command: format!("CONFIG:profile:mode:{}:toggle:{}", profile_name, mode),
|
||||
terminal: false,
|
||||
tags: vec!["config".into()],
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
// Profile actions
|
||||
vec![
|
||||
LaunchItem {
|
||||
id: format!("config:profile:{}:modes", profile_name),
|
||||
name: "Edit Modes".into(),
|
||||
description: Some(format!("Current: {}", profile.modes.join(", "))),
|
||||
icon: Some(PROVIDER_ICON.into()),
|
||||
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
|
||||
command: String::new(), // navigate with :config profile <name> modes
|
||||
terminal: false,
|
||||
tags: vec!["config".into()],
|
||||
},
|
||||
LaunchItem {
|
||||
id: format!("config:profile:{}:delete", profile_name),
|
||||
name: format!("Delete profile '{}'", profile_name),
|
||||
description: Some("Remove this profile".into()),
|
||||
icon: Some(PROVIDER_ICON.into()),
|
||||
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
|
||||
command: format!("CONFIG:profile:delete:{}", profile_name),
|
||||
terminal: false,
|
||||
tags: vec!["config".into()],
|
||||
},
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Write tests**
|
||||
|
||||
Add at the end of `config_editor.rs`:
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_config() -> Arc<RwLock<Config>> {
|
||||
Arc::new(RwLock::new(Config::default()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_top_level_categories() {
|
||||
let p = ConfigProvider::new(make_config());
|
||||
let items = p.query("");
|
||||
assert!(items.len() >= 8);
|
||||
assert!(items.iter().any(|i| i.name == "Providers"));
|
||||
assert!(items.iter().any(|i| i.name == "Theme"));
|
||||
assert!(items.iter().any(|i| i.name == "Profiles"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_toggles() {
|
||||
let p = ConfigProvider::new(make_config());
|
||||
let items = p.query("providers");
|
||||
assert!(items.len() >= 10);
|
||||
assert!(items.iter().any(|i| i.name.contains("Calculator")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toggle_action() {
|
||||
let config = make_config();
|
||||
let p = ConfigProvider::new(Arc::clone(&config));
|
||||
assert!(config.read().unwrap().providers.calculator);
|
||||
assert!(p.execute_action("CONFIG:toggle:providers.calculator"));
|
||||
assert!(!config.read().unwrap().providers.calculator);
|
||||
assert!(p.execute_action("CONFIG:toggle:providers.calculator"));
|
||||
assert!(config.read().unwrap().providers.calculator);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_theme() {
|
||||
let config = make_config();
|
||||
let p = ConfigProvider::new(Arc::clone(&config));
|
||||
assert!(p.execute_action("CONFIG:set:appearance.theme:nord"));
|
||||
assert_eq!(config.read().unwrap().appearance.theme, Some("nord".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_numeric() {
|
||||
let config = make_config();
|
||||
let p = ConfigProvider::new(Arc::clone(&config));
|
||||
assert!(p.execute_action("CONFIG:set:appearance.font_size:18"));
|
||||
assert_eq!(config.read().unwrap().appearance.font_size, 18);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_frecency_weight_clamped() {
|
||||
let config = make_config();
|
||||
let p = ConfigProvider::new(Arc::clone(&config));
|
||||
assert!(p.execute_action("CONFIG:set:providers.frecency_weight:2.0"));
|
||||
assert_eq!(config.read().unwrap().providers.frecency_weight, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_action() {
|
||||
let p = ConfigProvider::new(make_config());
|
||||
assert!(!p.execute_action("INVALID:something"));
|
||||
assert!(!p.execute_action("CONFIG:toggle:nonexistent.key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_theme_items_show_current() {
|
||||
let config = make_config();
|
||||
{
|
||||
config.write().unwrap().appearance.theme = Some("nord".into());
|
||||
}
|
||||
let p = ConfigProvider::new(config);
|
||||
let items = p.query("theme");
|
||||
let nord = items.iter().find(|i| i.name.contains("nord")).unwrap();
|
||||
assert!(nord.name.starts_with("● "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_numeric_input_generates_set_action() {
|
||||
let p = ConfigProvider::new(make_config());
|
||||
let items = p.query("fontsize 18");
|
||||
assert_eq!(items.len(), 1);
|
||||
assert!(items[0].name.contains("Set Font Size to 18"));
|
||||
assert_eq!(items[0].command, "CONFIG:set:appearance.font_size:18");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profile_create() {
|
||||
let config = make_config();
|
||||
let p = ConfigProvider::new(Arc::clone(&config));
|
||||
assert!(p.execute_action("CONFIG:profile:create:myprofile"));
|
||||
assert!(config.read().unwrap().profiles.contains_key("myprofile"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profile_delete() {
|
||||
let config = make_config();
|
||||
{
|
||||
config.write().unwrap().profiles.insert("test".into(), crate::config::ProfileConfig { modes: vec!["app".into()] });
|
||||
}
|
||||
let p = ConfigProvider::new(Arc::clone(&config));
|
||||
assert!(p.execute_action("CONFIG:profile:delete:test"));
|
||||
assert!(!config.read().unwrap().profiles.contains_key("test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profile_mode_toggle() {
|
||||
let config = make_config();
|
||||
{
|
||||
config.write().unwrap().profiles.insert("dev".into(), crate::config::ProfileConfig { modes: vec!["app".into()] });
|
||||
}
|
||||
let p = ConfigProvider::new(Arc::clone(&config));
|
||||
// Add ssh
|
||||
assert!(p.execute_action("CONFIG:profile:mode:dev:toggle:ssh"));
|
||||
assert!(config.read().unwrap().profiles["dev"].modes.contains(&"ssh".into()));
|
||||
// Remove app
|
||||
assert!(p.execute_action("CONFIG:profile:mode:dev:toggle:app"));
|
||||
assert!(!config.read().unwrap().profiles["dev"].modes.contains(&"app".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_type() {
|
||||
let p = ConfigProvider::new(make_config());
|
||||
assert_eq!(p.provider_type(), ProviderType::Plugin("config".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profile_create_query() {
|
||||
let p = ConfigProvider::new(make_config());
|
||||
let items = p.query("profile create myname");
|
||||
assert_eq!(items.len(), 1);
|
||||
assert!(items[0].name.contains("myname"));
|
||||
assert_eq!(items[0].command, "CONFIG:profile:create:myname");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Verify compilation and tests**
|
||||
|
||||
Run: `cargo test -p owlry-core config_editor`
|
||||
|
||||
Note: tests that call `execute_action` will try `config.save()` which writes to disk. The save will fail gracefully (warns) in test environment since there's no XDG config dir — the toggle/set still returns true. If tests fail due to save, add `#[allow(dead_code)]` or mock the save path. Alternatively, since `Config::save()` returns a Result and the provider logs but ignores errors, this should be fine.
|
||||
|
||||
Expected: All tests pass.
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/owlry-core/src/providers/config_editor.rs crates/owlry-core/src/providers/mod.rs
|
||||
git commit -m "feat(core): add built-in config editor provider
|
||||
|
||||
Interactive :config prefix for browsing and modifying settings.
|
||||
Supports provider toggles, theme/engine selection, numeric input,
|
||||
and profile CRUD. Uses CONFIG:* action commands that persist to
|
||||
config.toml via Config::save()."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Wire ConfigProvider into ProviderManager
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/owlry-core/src/providers/mod.rs`
|
||||
- Modify: `crates/owlry-core/src/config/mod.rs` (if ProfileConfig is not public)
|
||||
|
||||
The ConfigProvider needs to be:
|
||||
1. Registered as a built-in dynamic provider
|
||||
2. Its `execute_action` called from `execute_plugin_action`
|
||||
|
||||
- [ ] **Step 1: Make Config wrap in Arc<RwLock> for shared ownership**
|
||||
|
||||
The ConfigProvider needs mutable access to config. Currently `new_with_config` takes `&Config`. Change the daemon startup to wrap Config in `Arc<RwLock<Config>>` and pass it to both the ConfigProvider and the server.
|
||||
|
||||
In `crates/owlry-core/src/providers/mod.rs`, in `new_with_config()`, after creating the config provider:
|
||||
|
||||
```rust
|
||||
// Config editor — needs shared mutable access to config
|
||||
let config_arc = std::sync::Arc::new(std::sync::RwLock::new(config.clone()));
|
||||
builtin_dynamic.push(Box::new(config_editor::ConfigProvider::new(config_arc)));
|
||||
info!("Registered built-in config editor provider");
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Extend execute_plugin_action for built-in providers**
|
||||
|
||||
In `execute_plugin_action`, after the existing native provider check, add:
|
||||
|
||||
```rust
|
||||
// Check built-in config editor
|
||||
if command.starts_with("CONFIG:") {
|
||||
for provider in &self.builtin_dynamic {
|
||||
if let ProviderType::Plugin(ref id) = provider.provider_type() {
|
||||
if id == "config" {
|
||||
// Downcast to ConfigProvider to call execute_action
|
||||
// Since we can't downcast trait objects easily, add an
|
||||
// execute_action method to DynamicProvider with default impl
|
||||
return provider.execute_action(command);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For this to work, add `execute_action` to the `DynamicProvider` trait with a default no-op:
|
||||
|
||||
```rust
|
||||
pub(crate) trait DynamicProvider: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
fn provider_type(&self) -> ProviderType;
|
||||
fn query(&self, query: &str) -> Vec<LaunchItem>;
|
||||
fn priority(&self) -> u32;
|
||||
|
||||
/// Handle a plugin action command. Returns true if handled.
|
||||
fn execute_action(&self, _command: &str) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The ConfigProvider already has `execute_action` as an inherent method — just also implement it via the trait.
|
||||
|
||||
- [ ] **Step 3: Ensure ProfileConfig is accessible**
|
||||
|
||||
Check if `crate::config::ProfileConfig` is public. If not, add `pub` to its definition in `config/mod.rs`. The ConfigProvider needs to construct it for profile creation.
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `cargo test -p owlry-core --lib`
|
||||
|
||||
Expected: All tests pass (128+ existing + new config editor tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/owlry-core/src/providers/mod.rs crates/owlry-core/src/config/mod.rs
|
||||
git commit -m "feat(core): wire config editor into ProviderManager
|
||||
|
||||
Register ConfigProvider as built-in dynamic provider. Extend
|
||||
execute_plugin_action to dispatch CONFIG:* commands. Add
|
||||
execute_action method to DynamicProvider trait."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Update CLAUDE.md and README with config editor docs
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
|
||||
- [ ] **Step 1: Add config editor section to README**
|
||||
|
||||
In the README, in the Usage section (after Keyboard Shortcuts), add:
|
||||
|
||||
```markdown
|
||||
### Settings Editor
|
||||
|
||||
Type `:config` to browse and modify settings without editing files:
|
||||
|
||||
| Command | What it does |
|
||||
|---------|-------------|
|
||||
| `:config` | Show all setting categories |
|
||||
| `:config providers` | Toggle providers on/off |
|
||||
| `:config theme` | Select color theme |
|
||||
| `:config engine` | Select web search engine |
|
||||
| `:config frecency` | Toggle frecency, set weight |
|
||||
| `:config fontsize 16` | Set font size (restart to apply) |
|
||||
| `:config profiles` | List profiles |
|
||||
| `:config profile create dev` | Create a new profile |
|
||||
| `:config profile dev modes` | Edit which modes a profile includes |
|
||||
|
||||
Changes are saved to `config.toml` immediately. Some settings (theme, frecency) take effect on the next search. Others (font size, dimensions) require a restart.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add README.md
|
||||
git commit -m "docs: add config editor usage to README"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Execution Notes
|
||||
|
||||
### Task dependency order
|
||||
|
||||
Task 1 is the bulk of the implementation. Task 2 wires it in. Task 3 is docs.
|
||||
|
||||
**Order:** 1 → 2 → 3
|
||||
|
||||
### What's NOT in this plan
|
||||
|
||||
- **Hot-apply for theme** — would need the UI to re-trigger CSS loading after a CONFIG action. Can be added later by emitting a signal from the daemon or having the UI check a flag after `execute_plugin_action` returns.
|
||||
- **Profile rename via text input** — the current design supports `:config profile create <name>` but rename would need a two-step flow. Can be added later.
|
||||
- **Config file watching** — if the user edits `config.toml` externally, the ConfigProvider's cached `Arc<RwLock<Config>>` becomes stale. A file watcher could reload it. Deferred.
|
||||
1051
docs/superpowers/plans/2026-03-28-performance-hardening.md
Normal file
1051
docs/superpowers/plans/2026-03-28-performance-hardening.md
Normal file
File diff suppressed because it is too large
Load Diff
922
docs/superpowers/plans/2026-03-29-performance-optimization.md
Normal file
922
docs/superpowers/plans/2026-03-29-performance-optimization.md
Normal file
@@ -0,0 +1,922 @@
|
||||
# Performance Optimization Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Fix an unsound `unsafe` block, eliminate per-keystroke clone avalanches in the search path, and apply targeted I/O and algorithmic optimizations across both the `owlry` and `owlry-plugins` repos.
|
||||
|
||||
**Architecture:** Nine tasks across two repos. Phase 1 removes unsound `unsafe` code. Phase 2 restructures the hot search path to score by reference and clone only the final top-N results, combined with partial-sort (`select_nth_unstable_by`) for O(n) selection. Phase 3 removes unnecessary blocking I/O and simplifies GTK list updates. Phase 4 applies minor algorithmic fixes. Phase 5 covers plugin-repo fixes (separate repo, separate branch).
|
||||
|
||||
**Tech Stack:** Rust 1.90+, GTK4 4.12+, owlry-core, owlry-plugins
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|------|--------|----------------|
|
||||
| `crates/owlry-core/src/providers/native_provider.rs` | Modify | Remove `RwLock<Vec<LaunchItem>>`, eliminate `unsafe` block |
|
||||
| `crates/owlry-core/src/providers/mod.rs` | Modify | Score-by-reference in `search_with_frecency`, partial sort, clone only top N |
|
||||
| `crates/owlry-core/src/data/frecency.rs` | Modify | Remove auto-save in `record_launch` |
|
||||
| `crates/owlry/src/ui/main_window.rs` | Modify | Replace child-removal loop with `remove_all()` |
|
||||
| `crates/owlry-core/src/providers/application.rs` | Modify | Single-pass double-space cleanup |
|
||||
| `crates/owlry-core/src/filter.rs` | Modify | Merge full and partial prefix arrays into single-pass lookup |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Safety & Correctness
|
||||
|
||||
### Task 1: Remove unsound `unsafe` in NativeProvider::items()
|
||||
|
||||
The `items()` implementation creates a raw pointer from an `RwLockReadGuard`, then drops the guard while returning a slice backed by that pointer. This is UB waiting to happen. The inner `RwLock` is unnecessary — `refresh()` takes `&mut self` (exclusive access guaranteed by the outer `Arc<RwLock<ProviderManager>>`), and `items()` takes `&self`. Replace the `RwLock<Vec<LaunchItem>>` with a plain `Vec<LaunchItem>`.
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/owlry-core/src/providers/native_provider.rs`
|
||||
- Test: `crates/owlry-core/src/providers/native_provider.rs` (existing tests)
|
||||
|
||||
- [ ] **Step 1: Run existing tests to establish baseline**
|
||||
|
||||
Run: `cargo test -p owlry-core`
|
||||
|
||||
Expected: All tests PASS.
|
||||
|
||||
- [ ] **Step 2: Replace `RwLock<Vec<LaunchItem>>` with `Vec<LaunchItem>` in the struct**
|
||||
|
||||
In `crates/owlry-core/src/providers/native_provider.rs`, change the struct definition:
|
||||
|
||||
```rust
|
||||
pub struct NativeProvider {
|
||||
plugin: Arc<NativePlugin>,
|
||||
info: ProviderInfo,
|
||||
handle: ProviderHandle,
|
||||
items: Vec<LaunchItem>,
|
||||
}
|
||||
```
|
||||
|
||||
Update `new()` to initialize without RwLock:
|
||||
|
||||
```rust
|
||||
Self {
|
||||
plugin,
|
||||
info,
|
||||
handle,
|
||||
items: Vec::new(),
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove the `unsafe` block from `items()` — return `&self.items` directly**
|
||||
|
||||
Replace the entire `Provider::items()` impl:
|
||||
|
||||
```rust
|
||||
fn items(&self) -> &[LaunchItem] {
|
||||
&self.items
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update `refresh()` to write directly to `self.items`**
|
||||
|
||||
Replace the RwLock write in `refresh()`:
|
||||
|
||||
```rust
|
||||
// Was: *self.items.write().unwrap() = items;
|
||||
self.items = items;
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update `query()` to read from `self.items` directly**
|
||||
|
||||
In the `query()` method, replace the RwLock read:
|
||||
|
||||
```rust
|
||||
// Was: return self.items.read().unwrap().clone();
|
||||
return self.items.clone();
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Remove `use std::sync::RwLock;` (no longer needed, `Arc` still used for plugin)**
|
||||
|
||||
Remove `RwLock` from the `use std::sync::{Arc, RwLock};` import:
|
||||
|
||||
```rust
|
||||
use std::sync::Arc;
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Run tests and check**
|
||||
|
||||
Run: `cargo test -p owlry-core && cargo check -p owlry-core`
|
||||
|
||||
Expected: All tests PASS, no warnings about unused imports.
|
||||
|
||||
- [ ] **Step 8: Compile full workspace to verify no downstream breakage**
|
||||
|
||||
Run: `cargo check --workspace`
|
||||
|
||||
Expected: Clean compilation.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/owlry-core/src/providers/native_provider.rs
|
||||
git commit -m "fix(native-provider): remove unsound unsafe in items()
|
||||
|
||||
Replace RwLock<Vec<LaunchItem>> with plain Vec. The inner RwLock
|
||||
was unnecessary — refresh() takes &mut self (exclusive access
|
||||
guaranteed by the outer Arc<RwLock<ProviderManager>>). The unsafe
|
||||
block in items() dropped the RwLockReadGuard while returning a
|
||||
slice backed by the raw pointer, creating a dangling reference."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Hot Path Optimization
|
||||
|
||||
### Task 2: Eliminate clone avalanche in search_with_frecency
|
||||
|
||||
Currently every matching `LaunchItem` (5 Strings + Vec) is cloned during scoring, then the Vec is sorted and truncated to ~15 results — discarding 95%+ of clones. Refactor to score items by reference, partial-sort the lightweight `(&LaunchItem, i64)` tuples with `select_nth_unstable_by` (O(n) average), and clone only the top-N survivors.
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/owlry-core/src/providers/mod.rs` — `search_with_frecency` method (~lines 652-875)
|
||||
- Test: existing tests in `crates/owlry-core/src/providers/mod.rs`
|
||||
|
||||
- [ ] **Step 1: Run existing search tests to establish baseline**
|
||||
|
||||
Run: `cargo test -p owlry-core search`
|
||||
|
||||
Expected: All search-related tests PASS.
|
||||
|
||||
- [ ] **Step 2: Refactor `score_item` closure to return `Option<i64>` instead of `Option<(LaunchItem, i64)>`**
|
||||
|
||||
In `search_with_frecency`, change the closure at ~line 780 from:
|
||||
|
||||
```rust
|
||||
let score_item = |item: &LaunchItem| -> Option<(LaunchItem, i64)> {
|
||||
```
|
||||
|
||||
To:
|
||||
|
||||
```rust
|
||||
let score_item = |item: &LaunchItem| -> Option<i64> {
|
||||
```
|
||||
|
||||
And change the return from:
|
||||
|
||||
```rust
|
||||
base_score.map(|s| {
|
||||
let frecency_score = frecency.get_score_at(&item.id, now);
|
||||
let frecency_boost = (frecency_score * frecency_weight * 10.0) as i64;
|
||||
let exact_match_boost = if item.name.eq_ignore_ascii_case(query) {
|
||||
match &item.provider {
|
||||
ProviderType::Application => 50_000,
|
||||
_ => 30_000,
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
(item.clone(), s + frecency_boost + exact_match_boost)
|
||||
})
|
||||
```
|
||||
|
||||
To:
|
||||
|
||||
```rust
|
||||
base_score.map(|s| {
|
||||
let frecency_score = frecency.get_score_at(&item.id, now);
|
||||
let frecency_boost = (frecency_score * frecency_weight * 10.0) as i64;
|
||||
let exact_match_boost = if item.name.eq_ignore_ascii_case(query) {
|
||||
match &item.provider {
|
||||
ProviderType::Application => 50_000,
|
||||
_ => 30_000,
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
s + frecency_boost + exact_match_boost
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace the static-item scoring loops to collect references**
|
||||
|
||||
Replace the scoring loops at ~lines 831-853:
|
||||
|
||||
```rust
|
||||
// Was:
|
||||
// for provider in &self.providers { ... results.push(scored); }
|
||||
// for provider in &self.static_native_providers { ... results.push(scored); }
|
||||
|
||||
// Score static items by reference (no cloning)
|
||||
let mut scored_refs: Vec<(&LaunchItem, i64)> = Vec::new();
|
||||
|
||||
for provider in &self.providers {
|
||||
if !filter.is_active(provider.provider_type()) {
|
||||
continue;
|
||||
}
|
||||
for item in provider.items() {
|
||||
if let Some(score) = score_item(item) {
|
||||
scored_refs.push((item, score));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for provider in &self.static_native_providers {
|
||||
if !filter.is_active(provider.provider_type()) {
|
||||
continue;
|
||||
}
|
||||
for item in provider.items() {
|
||||
if let Some(score) = score_item(item) {
|
||||
scored_refs.push((item, score));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Partial sort: O(n) average to find top max_results, then O(k log k) to order them
|
||||
if scored_refs.len() > max_results {
|
||||
scored_refs.select_nth_unstable_by(max_results, |a, b| b.1.cmp(&a.1));
|
||||
scored_refs.truncate(max_results);
|
||||
}
|
||||
scored_refs.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
|
||||
// Clone only the survivors
|
||||
results.extend(scored_refs.into_iter().map(|(item, score)| (item.clone(), score)));
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add final merge-sort for dynamic + static results**
|
||||
|
||||
After extending results, add the final sort (dynamic results from earlier in the function + the newly added static results need unified ordering):
|
||||
|
||||
```rust
|
||||
// Final sort merges dynamic results with static top-N
|
||||
results.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
results.truncate(max_results);
|
||||
```
|
||||
|
||||
This replaces the existing `results.sort_by(...)` and `results.truncate(...)` lines at ~854-855 — the logic is the same, just confirming it's still present after the refactor.
|
||||
|
||||
- [ ] **Step 5: Optimize the empty-query path to score by reference too**
|
||||
|
||||
Replace the empty-query block at ~lines 739-776. Change:
|
||||
|
||||
```rust
|
||||
let core_items = self
|
||||
.providers
|
||||
.iter()
|
||||
.filter(|p| filter.is_active(p.provider_type()))
|
||||
.flat_map(|p| p.items().iter().cloned());
|
||||
|
||||
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| {
|
||||
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_at(&item.id, now);
|
||||
let boosted = (frecency_score * frecency_weight * 100.0) as i64;
|
||||
(item, boosted)
|
||||
})
|
||||
.collect();
|
||||
|
||||
results.extend(items);
|
||||
results.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
results.truncate(max_results);
|
||||
return results;
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```rust
|
||||
let mut scored_refs: Vec<(&LaunchItem, i64)> = self
|
||||
.providers
|
||||
.iter()
|
||||
.filter(|p| filter.is_active(p.provider_type()))
|
||||
.flat_map(|p| p.items().iter())
|
||||
.chain(
|
||||
self.static_native_providers
|
||||
.iter()
|
||||
.filter(|p| filter.is_active(p.provider_type()))
|
||||
.flat_map(|p| p.items().iter()),
|
||||
)
|
||||
.filter(|item| {
|
||||
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_at(&item.id, now);
|
||||
let boosted = (frecency_score * frecency_weight * 100.0) as i64;
|
||||
(item, boosted)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if scored_refs.len() > max_results {
|
||||
scored_refs.select_nth_unstable_by(max_results, |a, b| b.1.cmp(&a.1));
|
||||
scored_refs.truncate(max_results);
|
||||
}
|
||||
scored_refs.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
|
||||
results.extend(scored_refs.into_iter().map(|(item, score)| (item.clone(), score)));
|
||||
results.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
results.truncate(max_results);
|
||||
return results;
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run tests**
|
||||
|
||||
Run: `cargo test -p owlry-core`
|
||||
|
||||
Expected: All tests PASS. Search results are identical (same items, same ordering).
|
||||
|
||||
- [ ] **Step 7: Compile full workspace**
|
||||
|
||||
Run: `cargo check --workspace`
|
||||
|
||||
Expected: Clean compilation.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/owlry-core/src/providers/mod.rs
|
||||
git commit -m "perf(search): score by reference, clone only top-N results
|
||||
|
||||
Refactor search_with_frecency to score static provider items by
|
||||
reference (&LaunchItem, i64) instead of cloning every match.
|
||||
Use select_nth_unstable_by for O(n) partial sort, then clone
|
||||
only the max_results survivors. Reduces clones from O(total_matches)
|
||||
to O(max_results) — typically from hundreds to ~15."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: I/O Optimization
|
||||
|
||||
### Task 3: Remove frecency auto-save on every launch
|
||||
|
||||
`record_launch` calls `self.save()` synchronously — serializing JSON and writing to disk on every item launch. The `Drop` impl already saves on shutdown. Mark dirty and let the caller (or shutdown) handle persistence.
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/owlry-core/src/data/frecency.rs:98-123`
|
||||
- Test: `crates/owlry-core/src/data/frecency.rs` (existing + new)
|
||||
|
||||
- [ ] **Step 1: Write test verifying record_launch sets dirty without saving**
|
||||
|
||||
Add to the `#[cfg(test)]` block in `frecency.rs`:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn record_launch_sets_dirty_without_saving() {
|
||||
let mut store = FrecencyStore {
|
||||
data: FrecencyData::default(),
|
||||
path: PathBuf::from("/dev/null"),
|
||||
dirty: false,
|
||||
};
|
||||
|
||||
store.record_launch("test-item");
|
||||
|
||||
assert!(store.dirty, "record_launch should set dirty flag");
|
||||
assert_eq!(store.data.entries["test-item"].launch_count, 1);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails (current code auto-saves, clearing dirty)**
|
||||
|
||||
Run: `cargo test -p owlry-core record_launch_sets_dirty`
|
||||
|
||||
Expected: FAIL — `store.dirty` is `false` because `save()` clears it.
|
||||
|
||||
- [ ] **Step 3: Remove the auto-save from `record_launch`**
|
||||
|
||||
In `crates/owlry-core/src/data/frecency.rs`, remove lines 119-122 from `record_launch`:
|
||||
|
||||
```rust
|
||||
// Remove this block:
|
||||
// Auto-save after recording
|
||||
if let Err(e) = self.save() {
|
||||
warn!("Failed to save frecency data: {}", e);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cargo test -p owlry-core record_launch_sets_dirty`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Run all frecency tests**
|
||||
|
||||
Run: `cargo test -p owlry-core frecency`
|
||||
|
||||
Expected: All PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/owlry-core/src/data/frecency.rs
|
||||
git commit -m "perf(frecency): remove blocking auto-save on every launch
|
||||
|
||||
record_launch no longer calls save() synchronously. The dirty flag
|
||||
is set and the Drop impl flushes on shutdown. Removes a JSON
|
||||
serialize + fs::write from the hot launch path."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: GTK ListBox — replace child-removal loop with `remove_all()`
|
||||
|
||||
The current code removes children one-by-one in a `while` loop, triggering layout invalidation per removal. GTK 4.12 provides `remove_all()` which batches the operation.
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/owlry/src/ui/main_window.rs` — two locations (~lines 705-707 and ~742-744)
|
||||
|
||||
- [ ] **Step 1: Replace daemon-mode child removal loop**
|
||||
|
||||
In the `spawn_future_local` async block (~line 705), replace:
|
||||
|
||||
```rust
|
||||
while let Some(child) = results_list_cb.first_child() {
|
||||
results_list_cb.remove(&child);
|
||||
}
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```rust
|
||||
results_list_cb.remove_all();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace local-mode (dmenu) child removal loop**
|
||||
|
||||
In the synchronous (local/dmenu) branch (~line 742), replace:
|
||||
|
||||
```rust
|
||||
while let Some(child) = results_list.first_child() {
|
||||
results_list.remove(&child);
|
||||
}
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```rust
|
||||
results_list.remove_all();
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify compilation**
|
||||
|
||||
Run: `cargo check -p owlry`
|
||||
|
||||
Expected: Clean compilation. `remove_all()` is available in gtk4 4.12+.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/owlry/src/ui/main_window.rs
|
||||
git commit -m "perf(ui): use ListBox::remove_all() instead of per-child loop
|
||||
|
||||
Replaces two while-loop child removal patterns with the batched
|
||||
remove_all() method available since GTK 4.12. Avoids per-removal
|
||||
layout invalidation."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Minor Optimizations
|
||||
|
||||
### Task 5: Single-pass double-space cleanup in application.rs
|
||||
|
||||
The `clean_desktop_exec_field` function uses a `while contains(" ") { replace(" ", " ") }` loop — O(n²) on pathological input with repeated allocations. Replace with a single-pass char iterator.
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/owlry-core/src/providers/application.rs:60-64`
|
||||
- Test: existing tests in `crates/owlry-core/src/providers/application.rs`
|
||||
|
||||
- [ ] **Step 1: Add test for pathological input**
|
||||
|
||||
Add to the existing `#[cfg(test)]` block:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn test_clean_desktop_exec_collapses_spaces() {
|
||||
assert_eq!(clean_desktop_exec_field("app --flag arg"), "app --flag arg");
|
||||
// Pathological: many consecutive spaces
|
||||
let input = format!("app{}arg", " ".repeat(100));
|
||||
assert_eq!(clean_desktop_exec_field(&input), "app arg");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it passes with current implementation**
|
||||
|
||||
Run: `cargo test -p owlry-core clean_desktop_exec`
|
||||
|
||||
Expected: All PASS (current implementation works, just inefficiently).
|
||||
|
||||
- [ ] **Step 3: Replace the while-loop with a single-pass approach**
|
||||
|
||||
Replace lines 60-64 of `application.rs`:
|
||||
|
||||
```rust
|
||||
// Was:
|
||||
// let mut cleaned = result.trim().to_string();
|
||||
// while cleaned.contains(" ") {
|
||||
// cleaned = cleaned.replace(" ", " ");
|
||||
// }
|
||||
// cleaned
|
||||
|
||||
let trimmed = result.trim();
|
||||
let mut cleaned = String::with_capacity(trimmed.len());
|
||||
let mut prev_space = false;
|
||||
for c in trimmed.chars() {
|
||||
if c == ' ' {
|
||||
if !prev_space {
|
||||
cleaned.push(' ');
|
||||
}
|
||||
prev_space = true;
|
||||
} else {
|
||||
cleaned.push(c);
|
||||
prev_space = false;
|
||||
}
|
||||
}
|
||||
cleaned
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run all application tests**
|
||||
|
||||
Run: `cargo test -p owlry-core clean_desktop_exec`
|
||||
|
||||
Expected: All PASS including the new pathological test.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/owlry-core/src/providers/application.rs
|
||||
git commit -m "perf(application): single-pass double-space collapse
|
||||
|
||||
Replace while-contains-replace loop with a single-pass char
|
||||
iterator. Eliminates O(n²) behavior and repeated allocations
|
||||
on pathological desktop file Exec values."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Consolidate parse_query prefix matching into single pass
|
||||
|
||||
`parse_query` maintains four separate arrays (core_prefixes, plugin_prefixes, partial_core, partial_plugin) with duplicated prefix strings, iterating them in sequence. Merge full and partial matching into a single array and a single loop per category.
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/owlry-core/src/filter.rs:202-406`
|
||||
- Test: existing tests in `crates/owlry-core/src/filter.rs`
|
||||
|
||||
- [ ] **Step 1: Run existing filter tests to establish baseline**
|
||||
|
||||
Run: `cargo test -p owlry-core filter`
|
||||
|
||||
Expected: All PASS.
|
||||
|
||||
- [ ] **Step 2: Define a unified prefix entry struct and static arrays**
|
||||
|
||||
At the top of `parse_query`, replace the four separate arrays with two unified arrays:
|
||||
|
||||
```rust
|
||||
pub fn parse_query(query: &str) -> ParsedQuery {
|
||||
let trimmed = query.trim_start();
|
||||
|
||||
// Tag filter: ":tag:XXX query" — check first (unchanged)
|
||||
if let Some(rest) = trimmed.strip_prefix(":tag:") {
|
||||
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 {
|
||||
let tag = rest.to_lowercase();
|
||||
return ParsedQuery {
|
||||
prefix: None,
|
||||
tag_filter: Some(tag),
|
||||
query: String::new(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Core prefixes — each entry is tried as ":prefix " (full) and ":prefix" (partial)
|
||||
const CORE_PREFIXES: &[(&str, fn() -> ProviderType)] = &[
|
||||
("app", || ProviderType::Application),
|
||||
("apps", || ProviderType::Application),
|
||||
("cmd", || ProviderType::Command),
|
||||
("command", || ProviderType::Command),
|
||||
];
|
||||
|
||||
// Plugin prefixes — each entry maps to a plugin type_id
|
||||
const 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"),
|
||||
("config", "config"),
|
||||
("settings", "config"),
|
||||
("conv", "conv"),
|
||||
("converter", "conv"),
|
||||
];
|
||||
|
||||
// Single-pass: try each core prefix as both full (":prefix query") and partial (":prefix")
|
||||
for (name, make_provider) in CORE_PREFIXES {
|
||||
let with_space = format!(":{} ", name);
|
||||
if let Some(rest) = trimmed.strip_prefix(with_space.as_str()) {
|
||||
let provider = make_provider();
|
||||
#[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(),
|
||||
};
|
||||
}
|
||||
let exact = format!(":{}", name);
|
||||
if trimmed == exact {
|
||||
let provider = make_provider();
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!(
|
||||
"[Filter] parse_query({:?}) -> partial prefix {:?}",
|
||||
query, provider
|
||||
);
|
||||
return ParsedQuery {
|
||||
prefix: Some(provider),
|
||||
tag_filter: None,
|
||||
query: String::new(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Single-pass: try each plugin prefix as both full and partial
|
||||
for (name, type_id) in PLUGIN_PREFIXES {
|
||||
let with_space = format!(":{} ", name);
|
||||
if let Some(rest) = trimmed.strip_prefix(with_space.as_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(),
|
||||
};
|
||||
}
|
||||
let exact = format!(":{}", name);
|
||||
if trimmed == exact {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic plugin prefix fallback (unchanged)
|
||||
if let Some(rest) = trimmed.strip_prefix(':') {
|
||||
if let Some(space_idx) = rest.find(' ') {
|
||||
let prefix_word = &rest[..space_idx];
|
||||
if !prefix_word.is_empty()
|
||||
&& prefix_word
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
||||
{
|
||||
return ParsedQuery {
|
||||
prefix: Some(ProviderType::Plugin(prefix_word.to_string())),
|
||||
tag_filter: None,
|
||||
query: rest[space_idx + 1..].to_string(),
|
||||
};
|
||||
}
|
||||
} else if !rest.is_empty()
|
||||
&& rest
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
||||
{
|
||||
return ParsedQuery {
|
||||
prefix: Some(ProviderType::Plugin(rest.to_string())),
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
> **Note:** `CORE_PREFIXES` uses function pointers (`fn() -> ProviderType`) because `ProviderType::Application` and `ProviderType::Command` are fieldless variants that can be constructed in a const context via a trivial function. `PLUGIN_PREFIXES` stays as `(&str, &str)` because the `to_string()` allocation only happens once a prefix actually matches.
|
||||
|
||||
- [ ] **Step 3: Run all filter tests**
|
||||
|
||||
Run: `cargo test -p owlry-core filter`
|
||||
|
||||
Expected: All existing tests PASS (behavior unchanged).
|
||||
|
||||
- [ ] **Step 4: Run full check**
|
||||
|
||||
Run: `cargo check -p owlry-core`
|
||||
|
||||
Expected: Clean compilation.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/owlry-core/src/filter.rs
|
||||
git commit -m "refactor(filter): consolidate parse_query prefix arrays
|
||||
|
||||
Merge four separate prefix arrays (core full, plugin full, core
|
||||
partial, plugin partial) into two arrays with a single loop each
|
||||
that checks both full and partial match. Halves the data and
|
||||
eliminates the duplicate iteration."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Plugin Repo Fixes
|
||||
|
||||
> **These tasks target `somegit.dev/Owlibou/owlry-plugins` — a separate repository.**
|
||||
> Clone and branch separately. They can be done independently of Phases 1-4.
|
||||
|
||||
### Task 7: Filesearch — add minimum query length threshold
|
||||
|
||||
The filesearch plugin spawns an `fd` subprocess on every keystroke (after debounce). For short queries this is wasteful and returns too many results. Add a 3-character minimum before spawning.
|
||||
|
||||
**Files:**
|
||||
- Modify: `owlry-plugin-filesearch/src/lib.rs` — `query()` method
|
||||
- Test: existing or new tests
|
||||
|
||||
- [ ] **Step 1: Add early return in the `query` method**
|
||||
|
||||
At the top of the `query` function (which receives the search text), add:
|
||||
|
||||
```rust
|
||||
fn query(&self, query: &str) -> Vec<PluginItem> {
|
||||
// Don't spawn fd for very short queries — too many results, too slow
|
||||
if query.len() < 3 {
|
||||
return Vec::new();
|
||||
}
|
||||
// ... existing code ...
|
||||
}
|
||||
```
|
||||
|
||||
Find the correct method — it may be the `ProviderVTable::provider_query` path or a helper like `search_with_fd`. The guard should be placed at the earliest point before `Command::new("fd")` is invoked.
|
||||
|
||||
- [ ] **Step 2: Verify compilation and test**
|
||||
|
||||
Run: `cargo check -p owlry-plugin-filesearch`
|
||||
|
||||
Expected: Clean compilation.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add owlry-plugin-filesearch/src/lib.rs
|
||||
git commit -m "perf(filesearch): skip fd subprocess for queries under 3 chars
|
||||
|
||||
Avoids spawning a subprocess per keystroke when the user has
|
||||
only typed 1-2 characters. Short queries return too many results
|
||||
from fd and block the daemon's read lock."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Emoji plugin — avoid double clone on refresh
|
||||
|
||||
The emoji provider's `refresh()` returns `state.items.to_vec().into()` which clones all ~400 `PluginItem` structs. The core's `NativeProvider::refresh()` then converts each to `LaunchItem` (another set of allocations). If the plugin API supports transferring ownership instead of cloning, use that. Otherwise, this is an API-level limitation.
|
||||
|
||||
**Files:**
|
||||
- Modify: `owlry-plugin-emoji/src/lib.rs` — `provider_refresh` function
|
||||
|
||||
- [ ] **Step 1: Check if items can be drained instead of cloned**
|
||||
|
||||
If `state.items` is a `Vec<PluginItem>` that gets rebuilt on each refresh anyway, drain it:
|
||||
|
||||
```rust
|
||||
// Was: state.items.to_vec().into()
|
||||
// If items are rebuilt each refresh:
|
||||
std::mem::take(&mut state.items).into()
|
||||
```
|
||||
|
||||
If `state.items` must be preserved between refreshes (because refresh is called multiple times and the items don't change), then the clone is necessary and this task is a no-op. Check the `refresh()` implementation to determine which case applies.
|
||||
|
||||
- [ ] **Step 2: Verify and commit if applicable**
|
||||
|
||||
Run: `cargo check -p owlry-plugin-emoji`
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Clipboard plugin — add caching for `cliphist list`
|
||||
|
||||
The clipboard provider calls `cliphist list` synchronously on every refresh. If the daemon's periodic refresh timer triggers this, it blocks the RwLock. Add a simple staleness check — only re-run `cliphist list` if more than N seconds have elapsed since the last successful fetch.
|
||||
|
||||
**Files:**
|
||||
- Modify: `owlry-plugin-clipboard/src/lib.rs`
|
||||
|
||||
- [ ] **Step 1: Add a `last_refresh` timestamp to the provider state**
|
||||
|
||||
```rust
|
||||
use std::time::Instant;
|
||||
|
||||
struct ClipboardState {
|
||||
items: Vec<PluginItem>,
|
||||
last_refresh: Option<Instant>,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Guard the subprocess call with a staleness check**
|
||||
|
||||
In the `refresh()` path:
|
||||
|
||||
```rust
|
||||
const REFRESH_INTERVAL: Duration = Duration::from_secs(5);
|
||||
|
||||
if let Some(last) = state.last_refresh {
|
||||
if last.elapsed() < REFRESH_INTERVAL {
|
||||
return state.items.clone().into();
|
||||
}
|
||||
}
|
||||
|
||||
// ... existing cliphist list call ...
|
||||
|
||||
state.last_refresh = Some(Instant::now());
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify and commit**
|
||||
|
||||
Run: `cargo check -p owlry-plugin-clipboard`
|
||||
|
||||
```bash
|
||||
git commit -m "perf(clipboard): cache cliphist results for 5 seconds
|
||||
|
||||
Avoids re-spawning cliphist list on every refresh cycle when the
|
||||
previous results are still fresh."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Task | Impact | Repo | Risk |
|
||||
|------|--------|------|------|
|
||||
| 1. Remove unsafe in NativeProvider | CRITICAL (soundness) | owlry | Low — drops unnecessary RwLock |
|
||||
| 2. Score-by-ref + partial sort | HIGH (keystroke perf) | owlry | Medium — touches hot path, verify with tests |
|
||||
| 3. Remove frecency auto-save | MEDIUM (launch perf) | owlry | Low — Drop impl already saves |
|
||||
| 4. ListBox remove_all() | MEDIUM (UI smoothness) | owlry | Low — direct GTK API replacement |
|
||||
| 5. Single-pass space collapse | LOW (startup) | owlry | Low — purely algorithmic |
|
||||
| 6. Consolidate parse_query | LOW (keystroke) | owlry | Low — existing tests cover behavior |
|
||||
| 7. Filesearch min query length | HIGH (keystroke perf) | plugins | Low — early return guard |
|
||||
| 8. Emoji refresh optimization | MEDIUM (startup) | plugins | Low — depends on API check |
|
||||
| 9. Clipboard caching | MEDIUM (refresh perf) | plugins | Low — simple staleness check |
|
||||
142
docs/superpowers/specs/2026-03-26-codebase-hardening-design.md
Normal file
142
docs/superpowers/specs/2026-03-26-codebase-hardening-design.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# Codebase Hardening: owlry + owlry-plugins
|
||||
|
||||
**Date:** 2026-03-26
|
||||
**Scope:** 15 fixes across 2 repositories, organized in 5 severity tiers
|
||||
**Approach:** Severity-ordered tiers, one commit per tier, core repo first
|
||||
|
||||
---
|
||||
|
||||
## Tier 1: Critical / Soundness (owlry core)
|
||||
|
||||
### 1a. Replace `static mut HOST_API` with `OnceLock`
|
||||
|
||||
**File:** `crates/owlry-plugin-api/src/lib.rs`
|
||||
**Problem:** `static mut` is unsound — concurrent reads during initialization are UB.
|
||||
**Fix:** Replace with `std::sync::OnceLock<&'static HostAPI>`. `init_host_api()` calls `HOST_API.set(api)`, `host_api()` calls `HOST_API.get().copied()`. No public API changes — convenience wrappers (`notify()`, `log_info()`, etc.) keep working. No ABI impact since `HOST_API` is internal.
|
||||
|
||||
### 1b. Add IPC message size limit
|
||||
|
||||
**File:** `crates/owlry-core/src/server.rs`
|
||||
**Problem:** `BufReader::lines()` reads unbounded lines. A malicious/buggy client can OOM the daemon.
|
||||
**Fix:** Replace the `lines()` iterator with a manual `read_line()` loop enforcing a 1 MB max. Lines exceeding the limit get an error response and the connection is dropped. Constant: `const MAX_REQUEST_SIZE: usize = 1_048_576`.
|
||||
|
||||
### 1c. Handle mutex poisoning gracefully
|
||||
|
||||
**File:** `crates/owlry-core/src/server.rs`
|
||||
**Problem:** All `lock().unwrap()` calls panic on poisoned mutex, crashing handler threads.
|
||||
**Fix:** Replace with `lock().unwrap_or_else(|e| e.into_inner())`. The ProviderManager and FrecencyStore don't have invariants that require abort-on-poison.
|
||||
|
||||
---
|
||||
|
||||
## Tier 2: Security (owlry core)
|
||||
|
||||
### 2a. Set socket permissions after bind
|
||||
|
||||
**File:** `crates/owlry-core/src/server.rs`
|
||||
**Problem:** Socket inherits process umask, may be readable by other local users.
|
||||
**Fix:** After `UnixListener::bind()`, call `std::fs::set_permissions(socket_path, Permissions::from_mode(0o600))`. Uses `std::os::unix::fs::PermissionsExt`.
|
||||
|
||||
### 2b. Log signal handler failure
|
||||
|
||||
**File:** `crates/owlry-core/src/main.rs`
|
||||
**Problem:** `ctrlc::set_handler(...).ok()` silently swallows errors. Failed handler means no socket cleanup on SIGINT.
|
||||
**Fix:** Replace `.ok()` with `if let Err(e) = ... { warn!("...") }`.
|
||||
|
||||
### 2c. Add client read timeout
|
||||
|
||||
**File:** `crates/owlry-core/src/server.rs`
|
||||
**Problem:** A client that connects but never sends data blocks a thread forever.
|
||||
**Fix:** Set `stream.set_read_timeout(Some(Duration::from_secs(30)))` on accepted connections before entering the read loop.
|
||||
|
||||
---
|
||||
|
||||
## Tier 3: Robustness / Quality (owlry core)
|
||||
|
||||
### 3a. Log malformed JSON requests
|
||||
|
||||
**File:** `crates/owlry-core/src/server.rs`
|
||||
**Problem:** JSON parse errors only sent as response to client, not visible in daemon logs.
|
||||
**Fix:** Add `warn!("Malformed request from client: {}", e)` before sending the error response.
|
||||
|
||||
### 3b. Replace Mutex with RwLock for concurrent reads
|
||||
|
||||
**File:** `crates/owlry-core/src/server.rs`
|
||||
**Problem:** `Mutex<ProviderManager>` blocks all concurrent queries even though they're read-only.
|
||||
**Fix:** Replace both `Arc<Mutex<ProviderManager>>` and `Arc<Mutex<FrecencyStore>>` with `Arc<RwLock<...>>`.
|
||||
|
||||
Lock usage per request type:
|
||||
|
||||
| Request | ProviderManager | FrecencyStore |
|
||||
|---------|----------------|---------------|
|
||||
| Query | `read()` | `read()` |
|
||||
| Launch | — | `write()` |
|
||||
| Providers | `read()` | — |
|
||||
| Refresh | `write()` | — |
|
||||
| Toggle | — | — |
|
||||
| Submenu | `read()` | — |
|
||||
| PluginAction | `read()` | — |
|
||||
|
||||
Poisoning recovery: `.unwrap_or_else(|e| e.into_inner())` applies to RwLock the same way.
|
||||
|
||||
---
|
||||
|
||||
## Tier 4: Critical fixes (owlry-plugins)
|
||||
|
||||
### 4a. Fix `Box::leak` memory leak in converter
|
||||
|
||||
**File:** `owlry-plugins/crates/owlry-plugin-converter/src/units.rs`
|
||||
**Problem:** `Box::leak(code.into_boxed_str())` leaks memory on every keystroke for currency queries.
|
||||
**Fix:** Currency codes are already `&'static str` in `CURRENCY_ALIASES`. Change `resolve_currency_code()` return type from `Option<String>` to `Option<&'static str>` so it returns the static str directly. This eliminates the `Box::leak`. Callers in `units.rs` (`find_unit`, `convert_currency`, `convert_currency_common`) and `currency.rs` (`is_currency_alias`) must be updated to work with `&'static str` — mostly removing `.to_string()` calls or adding them at the boundary where `String` is needed (e.g., HashMap lookups that need owned keys).
|
||||
|
||||
### 4b. Fix bookmarks temp file race condition
|
||||
|
||||
**File:** `owlry-plugins/crates/owlry-plugin-bookmarks/src/lib.rs`
|
||||
**Problem:** Predictable `/tmp/owlry_places_temp.sqlite` path — concurrent instances clobber, symlink attacks possible.
|
||||
**Fix:** Append PID and monotonic counter to filename: `owlry_places_{pid}.sqlite`. Uses `std::process::id()`. Each profile copy gets its own name via index. Cleanup on exit remains the same.
|
||||
|
||||
### 4c. Fix bookmarks background refresh never updating state
|
||||
|
||||
**File:** `owlry-plugins/crates/owlry-plugin-bookmarks/src/lib.rs`
|
||||
**Problem:** Background thread loads items and saves cache but never writes back to `self.items`. Current session keeps stale data.
|
||||
**Fix:** Replace `items: Vec<PluginItem>` with `items: Arc<Mutex<Vec<PluginItem>>>`. Background thread writes to the shared vec after completing. `provider_refresh` reads from it. The `loading` AtomicBool already prevents concurrent loads.
|
||||
|
||||
---
|
||||
|
||||
## Tier 5: Quality fixes (owlry-plugins)
|
||||
|
||||
### 5a. SSH plugin: read terminal from config
|
||||
|
||||
**File:** `owlry-plugins/crates/owlry-plugin-ssh/src/lib.rs`
|
||||
**Problem:** Hardcoded `kitty` as terminal fallback. Core already detects terminals.
|
||||
**Fix:** Read `terminal` from `[plugins.ssh]` in `~/.config/owlry/config.toml`. Fall back to `$TERMINAL` env var, then `xdg-terminal-exec`. Same config pattern as weather/pomodoro plugins.
|
||||
|
||||
### 5b. WebSearch plugin: read engine from config
|
||||
|
||||
**File:** `owlry-plugins/crates/owlry-plugin-websearch/src/lib.rs`
|
||||
**Problem:** TODO comment for config reading, never implemented. Engine is always duckduckgo.
|
||||
**Fix:** Read `engine` from `[plugins.websearch]` in config.toml. Supports named engines (`google`, `duckduckgo`, etc.) or custom URL templates with `{query}`. Falls back to duckduckgo.
|
||||
|
||||
### 5c. Emoji plugin: build items once at init
|
||||
|
||||
**File:** `owlry-plugins/crates/owlry-plugin-emoji/src/lib.rs`
|
||||
**Problem:** `load_emojis()` clears and rebuilds ~370 items on every `refresh()` call.
|
||||
**Fix:** Call `load_emojis()` in `EmojiState::new()`. `provider_refresh` returns `self.items.clone()` without rebuilding.
|
||||
|
||||
### 5d. Calculator/Converter: safer shell commands
|
||||
|
||||
**Files:** `owlry-plugin-calculator/src/lib.rs`, `owlry-plugin-converter/src/lib.rs`
|
||||
**Problem:** `sh -c 'echo -n "..."'` pattern with double-quote interpolation. Theoretically breakable by unexpected result formatting.
|
||||
**Fix:** Use `printf '%s' '...' | wl-copy` with single-quote escaping (`replace('\'', "'\\''")`) — the same safe pattern already used by bookmarks and clipboard plugins.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope
|
||||
|
||||
These were identified but deferred:
|
||||
|
||||
- **Hardcoded emoji list** — replacing with a crate/data file is a feature, not a fix
|
||||
- **Plugin vtable-level tests** — valuable but a separate testing initiative
|
||||
- **IPC protocol versioning** — protocol change, not a bug fix
|
||||
- **Plugin sandbox enforcement** — large feature, not a point fix
|
||||
- **Desktop Exec field sanitization** — deep rabbit hole, needs separate design
|
||||
- **Config validation** — separate concern, deserves its own pass
|
||||
161
docs/superpowers/specs/2026-03-26-runtime-integration-design.md
Normal file
161
docs/superpowers/specs/2026-03-26-runtime-integration-design.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# Script Runtime Integration for owlry-core Daemon
|
||||
|
||||
**Date:** 2026-03-26
|
||||
**Scope:** Wire up Lua/Rune script runtime loading in the daemon, fix ABI mismatch, add filesystem-watching hot-reload, update plugin documentation
|
||||
**Repos:** owlry (core), owlry-plugins (docs only)
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
The daemon (`owlry-core`) only loads native plugins from `/usr/lib/owlry/plugins/`. User script plugins in `~/.config/owlry/plugins/` are never discovered because `ProviderManager::new_with_config()` never calls the `LoadedRuntime` infrastructure that already exists in `runtime_loader.rs`. Both Lua and Rune runtimes are installed at `/usr/lib/owlry/runtimes/` and functional, but never invoked.
|
||||
|
||||
Additionally, the Lua runtime's `RuntimeInfo` struct has 5 fields while the core expects 2, causing a SIGSEGV on cleanup.
|
||||
|
||||
---
|
||||
|
||||
## 1. Fix Lua RuntimeInfo ABI mismatch
|
||||
|
||||
**File:** `owlry/crates/owlry-lua/src/lib.rs`
|
||||
|
||||
Shrink Lua's `RuntimeInfo` from 5 fields to 2, matching core and Rune:
|
||||
|
||||
```rust
|
||||
// Before (5 fields — ABI mismatch with core):
|
||||
pub struct RuntimeInfo {
|
||||
pub id: RString,
|
||||
pub name: RString,
|
||||
pub version: RString,
|
||||
pub description: RString,
|
||||
pub api_version: u32,
|
||||
}
|
||||
|
||||
// After (2 fields — matches core/Rune):
|
||||
pub struct RuntimeInfo {
|
||||
pub name: RString,
|
||||
pub version: RString,
|
||||
}
|
||||
```
|
||||
|
||||
Update `runtime_info()` to return only 2 fields. Remove the `LUA_RUNTIME_API_VERSION` constant and `LuaRuntimeVTable` (use the core's `ScriptRuntimeVTable` layout — both already match). The extra metadata (`id`, `description`) was never consumed by the core.
|
||||
|
||||
### Vtable `init` signature change
|
||||
|
||||
Change the `init` function in the vtable to accept the owlry version as a second parameter:
|
||||
|
||||
```rust
|
||||
// Before:
|
||||
pub init: extern "C" fn(plugins_dir: RStr<'_>) -> RuntimeHandle,
|
||||
|
||||
// After:
|
||||
pub init: extern "C" fn(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle,
|
||||
```
|
||||
|
||||
This applies to:
|
||||
- `owlry-core/src/plugins/runtime_loader.rs` — `ScriptRuntimeVTable.init`
|
||||
- `owlry-lua/src/lib.rs` — `LuaRuntimeVTable.init` and `runtime_init()` implementation
|
||||
- `owlry-rune/src/lib.rs` — `RuneRuntimeVTable.init` and `runtime_init()` implementation
|
||||
|
||||
The core passes its version (`env!("CARGO_PKG_VERSION")` from `owlry-core`) when calling `(vtable.init)(plugins_dir, version)`. Runtimes forward it to `discover_and_load()` instead of hardcoding a version string. This keeps compatibility checks future-proof — no code changes needed on version bumps.
|
||||
|
||||
---
|
||||
|
||||
## 2. Change default entry points to `main`
|
||||
|
||||
**Files:**
|
||||
- `owlry/crates/owlry-lua/src/manifest.rs` — change `default_entry()` from `"init.lua"` to `"main.lua"`
|
||||
- `owlry/crates/owlry-rune/src/manifest.rs` — change `default_entry()` from `"init.rn"` to `"main.rn"`
|
||||
|
||||
Add `#[serde(alias = "entry_point")]` to the `entry` field in both manifests so existing `plugin.toml` files using `entry_point` continue to work.
|
||||
|
||||
---
|
||||
|
||||
## 3. Wire runtime loading into ProviderManager
|
||||
|
||||
**File:** `owlry/crates/owlry-core/src/providers/mod.rs`
|
||||
|
||||
In `ProviderManager::new_with_config()`, after native plugin loading:
|
||||
|
||||
1. Get user plugins directory from `paths::plugins_dir()`
|
||||
2. Get owlry version: `env!("CARGO_PKG_VERSION")`
|
||||
3. Try `LoadedRuntime::load_lua(&plugins_dir, version)` — log at `info!` if unavailable, not error
|
||||
4. Try `LoadedRuntime::load_rune(&plugins_dir, version)` — same
|
||||
5. Call `create_providers()` on each loaded runtime
|
||||
6. Feed runtime providers into existing categorization (static/dynamic/widget)
|
||||
|
||||
`LoadedRuntime::load_lua`, `load_rune`, and `load_from_path` all gain an `owlry_version: &str` parameter, which is passed to `(vtable.init)(plugins_dir, owlry_version)`.
|
||||
|
||||
Store `LoadedRuntime` instances on `ProviderManager` in a new field `runtimes: Vec<LoadedRuntime>`. These must stay alive for the daemon's lifetime (they own the `Library` handle via `Arc`).
|
||||
|
||||
Remove `#![allow(dead_code)]` from `runtime_loader.rs` since it's now used.
|
||||
|
||||
---
|
||||
|
||||
## 4. Filesystem watcher for automatic hot-reload
|
||||
|
||||
**New file:** `owlry/crates/owlry-core/src/plugins/watcher.rs`
|
||||
**Modified:** `owlry/crates/owlry-core/src/providers/mod.rs`, `Cargo.toml`
|
||||
|
||||
### Dependencies
|
||||
|
||||
Add to `owlry-core/Cargo.toml`:
|
||||
```toml
|
||||
notify = "7"
|
||||
notify-debouncer-mini = "0.5"
|
||||
```
|
||||
|
||||
### Watcher design
|
||||
|
||||
After initializing runtimes, spawn a background watcher thread:
|
||||
|
||||
1. Watch `~/.config/owlry/plugins/` recursively using `notify-debouncer-mini` with 500ms debounce
|
||||
2. On debounced event (any file create/modify/delete):
|
||||
- Acquire write lock on `ProviderManager`
|
||||
- Remove all runtime-backed providers from the provider vecs
|
||||
- Drop old `LoadedRuntime` instances
|
||||
- Re-load runtimes from `/usr/lib/owlry/runtimes/` with fresh plugin discovery
|
||||
- Add new runtime providers to provider vecs
|
||||
- Refresh the new providers
|
||||
- Release write lock
|
||||
|
||||
### Provider tracking
|
||||
|
||||
`ProviderManager` needs to distinguish runtime providers from native/core providers for selective removal during reload. Options:
|
||||
|
||||
- **Tag-based:** Runtime providers already use `ProviderType::Plugin(type_id)`. Keep a `HashSet<String>` of type_ids that came from runtimes. On reload, remove providers whose type_id is in the set.
|
||||
- **Separate storage:** Store runtime providers in their own vec, separate from native providers. Query merges results from both.
|
||||
|
||||
**Chosen: Tag-based.** Simpler — runtime type_ids are tracked in a `runtime_type_ids: HashSet<String>` on `ProviderManager`. Reload clears the set, removes matching providers, then re-adds.
|
||||
|
||||
### Thread communication
|
||||
|
||||
The watcher thread needs access to `Arc<RwLock<ProviderManager>>`. The `Server` already holds this Arc. Pass a clone to the watcher thread at startup. The watcher acquires `write()` only during reload (~10ms), so read contention is minimal.
|
||||
|
||||
### Watcher lifecycle
|
||||
|
||||
- Started in `Server::run()` (or `Server::bind()`) before the accept loop
|
||||
- Runs until the daemon exits (watcher thread is detached or joined on drop)
|
||||
- Errors in the watcher (e.g., inotify limit exceeded) are logged and the watcher stops — daemon continues without hot-reload
|
||||
|
||||
---
|
||||
|
||||
## 5. Plugin development documentation
|
||||
|
||||
**File:** `owlry-plugins/docs/PLUGIN_DEVELOPMENT.md`
|
||||
|
||||
Cover:
|
||||
- **Plugin directory structure** — `~/.config/owlry/plugins/<name>/plugin.toml` + `main.lua`/`main.rn`
|
||||
- **Manifest reference** — all `plugin.toml` fields (`id`, `name`, `version`, `description`, `entry`/`entry_point`, `owlry_version`, `[[providers]]` section, `[permissions]` section)
|
||||
- **Lua plugin guide** — `owlry.provider.register()` API with `refresh` and `query` callbacks, item table format (`id`, `name`, `command`, `description`, `icon`, `terminal`, `tags`)
|
||||
- **Rune plugin guide** — `pub fn refresh()` and `pub fn query(q)` signatures, `Item::new()` builder, `use owlry::Item`
|
||||
- **Hot-reload** — changes are picked up automatically, no daemon restart needed
|
||||
- **Examples** — complete working examples for both Lua and Rune
|
||||
|
||||
---
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Config-gated runtime loading (runtimes self-skip if `.so` not installed)
|
||||
- Per-plugin selective reload (full runtime reload is fast enough)
|
||||
- Plugin registry/installation (already exists in the CLI)
|
||||
- Sandbox enforcement (separate concern, deferred from hardening spec)
|
||||
116
docs/superpowers/specs/2026-03-28-builtin-providers-design.md
Normal file
116
docs/superpowers/specs/2026-03-28-builtin-providers-design.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Built-in Providers Migration — Design Spec
|
||||
|
||||
## Goal
|
||||
|
||||
Move calculator, converter, and system from external `.so` plugins (owlry-plugins repo) to native providers compiled into `owlry-core`. Remove 3 plugin AUR packages (transitional), 4 meta AUR packages (already deleted). Update READMEs for both repos.
|
||||
|
||||
## Architecture
|
||||
|
||||
The 3 plugins currently use the FFI plugin API (`PluginVTable`, `PluginItem`, etc.) and are loaded as `.so` files by `NativePluginLoader`. As built-in providers, they become native Rust modules inside `owlry-core/src/providers/` implementing the existing `Provider` trait — same as `ApplicationProvider` and `CommandProvider`.
|
||||
|
||||
No changes to the plugin system itself. External plugins continue to work via `.so` loading.
|
||||
|
||||
## Components
|
||||
|
||||
### New modules in owlry-core
|
||||
|
||||
- `providers/calculator.rs` — port of owlry-plugin-calculator (231 lines, depends on `meval`)
|
||||
- `providers/converter/mod.rs` — port of owlry-plugin-converter entry point
|
||||
- `providers/converter/parser.rs` — query parsing (235 lines, no new deps)
|
||||
- `providers/converter/units.rs` — unit definitions + conversion (944 lines, no new deps)
|
||||
- `providers/converter/currency.rs` — ECB rate fetching (313 lines, depends on `reqwest` blocking + `dirs` + `serde`)
|
||||
- `providers/system.rs` — port of owlry-plugin-system (257 lines, no new deps)
|
||||
|
||||
### New owlry-core dependencies
|
||||
|
||||
- `meval` — math expression evaluation (currently optional behind `lua` feature, make required)
|
||||
- `reqwest` with `blocking` feature — ECB currency rate fetching (currently optional behind `lua`, make required)
|
||||
- `dirs` — already a dependency
|
||||
- `serde`/`serde_json` — already dependencies
|
||||
|
||||
### Modified files
|
||||
|
||||
- `owlry-core/src/providers/mod.rs` — register the 3 new providers in `ProviderManager`, honor config toggles, classify calculator+converter as dynamic providers
|
||||
- `owlry-core/Cargo.toml` — move `meval` and `reqwest` from optional to required
|
||||
- `owlry-core/src/config/mod.rs` — add `converter` config toggle (calculator and system already exist)
|
||||
|
||||
### Provider classification
|
||||
|
||||
- Calculator → dynamic (queried per-keystroke via `query()`)
|
||||
- Converter → dynamic (queried per-keystroke via `query()`)
|
||||
- System → static (populated at `refresh()`, returns fixed list of actions)
|
||||
|
||||
## Provider Type IDs
|
||||
|
||||
Built-in providers use `ProviderType::Plugin(String)` with fixed IDs to maintain backward compatibility with the UI highlighting and filter system:
|
||||
|
||||
- Calculator: `ProviderType::Plugin("calc".into())`
|
||||
- Converter: `ProviderType::Plugin("conv".into())`
|
||||
- System: `ProviderType::Plugin("sys".into())`
|
||||
|
||||
This ensures the UI's highlighting logic (`matches!(id.as_str(), "calc" | "conv")`) and CSS badge classes (`.owlry-badge-calc`, `.owlry-badge-sys`) continue to work without changes.
|
||||
|
||||
## Config
|
||||
|
||||
Existing toggles in `[providers]`:
|
||||
|
||||
```toml
|
||||
[providers]
|
||||
calculator = true # already exists
|
||||
system = true # already exists
|
||||
converter = true # new — add with default true
|
||||
```
|
||||
|
||||
When a toggle is false, the provider is not registered in `ProviderManager` at startup.
|
||||
|
||||
## Currency Conversion
|
||||
|
||||
The converter's currency feature uses `reqwest` (blocking) to fetch ECB exchange rates with a 24-hour file cache at `~/.cache/owlry/ecb_rates.json`. If the HTTP fetch fails (no network, timeout), currency conversion silently returns no results — unit conversion still works. This matches current plugin behavior.
|
||||
|
||||
## AUR Changes
|
||||
|
||||
### Main repo (owlry)
|
||||
|
||||
- `aur/owlry-core/PKGBUILD` — bump version
|
||||
- Remove `aur/owlry-meta-*` directories (4 dirs, already deleted from AUR)
|
||||
|
||||
### Plugins repo (owlry-plugins)
|
||||
|
||||
- Remove crates: `owlry-plugin-calculator`, `owlry-plugin-converter`, `owlry-plugin-system`
|
||||
- Remove AUR dirs: `aur/owlry-plugin-calculator`, `aur/owlry-plugin-converter`, `aur/owlry-plugin-system` from tracked files
|
||||
- Push transitional PKGBUILDs to the 3 AUR repos:
|
||||
|
||||
```bash
|
||||
pkgname=owlry-plugin-calculator # (and converter, system)
|
||||
pkgver=<last_version>
|
||||
pkgrel=99
|
||||
pkgdesc="Transitional package — calculator is now built into owlry-core"
|
||||
arch=('any')
|
||||
depends=('owlry-core>=<new_version>')
|
||||
replaces=('owlry-plugin-calculator')
|
||||
# No source, no build, no package body
|
||||
```
|
||||
|
||||
### Conflict prevention
|
||||
|
||||
When owlry-core gains built-in calculator/converter/system, users who have the old `.so` plugins installed will have both the built-in provider AND the `.so` plugin active — duplicate results. The daemon should detect this: if a built-in provider ID matches a loaded native plugin ID, skip the native plugin. Add this check in `ProviderManager` when registering native plugins.
|
||||
|
||||
## README Updates
|
||||
|
||||
### Main repo README
|
||||
|
||||
- Package table: remove separate plugin entries for calculator, converter, system — note them as built-in to owlry-core
|
||||
- Remove meta package section entirely
|
||||
- Update install examples (no need to install calculator/converter/system separately)
|
||||
|
||||
### Plugins repo README
|
||||
|
||||
- Remove calculator, converter, system from plugin listing
|
||||
- Add note that these 3 are built into owlry-core
|
||||
|
||||
## Testing
|
||||
|
||||
- Port existing plugin tests directly — they test provider logic, not FFI wrappers
|
||||
- `cargo test -p owlry-core --lib` covers all 3 new providers
|
||||
- Add conflict detection test (built-in provider ID vs native plugin ID)
|
||||
- Manual verification: `= 5+3` (calc), `20F` (conv), `20 euro to dollar` (currency), system actions
|
||||
187
docs/superpowers/specs/2026-03-28-config-editor-design.md
Normal file
187
docs/superpowers/specs/2026-03-28-config-editor-design.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# Config Editor — Design Spec
|
||||
|
||||
## Goal
|
||||
|
||||
A built-in provider in owlry-core that lets users browse and modify their configuration directly from the launcher UI, without opening a text editor.
|
||||
|
||||
## Scope
|
||||
|
||||
### Editable settings (curated)
|
||||
|
||||
**Provider toggles** (boolean):
|
||||
- applications, commands, calculator, converter, system
|
||||
- websearch, ssh, clipboard, bookmarks, emoji, scripts, files
|
||||
- media, weather, pomodoro
|
||||
- uuctl (systemd user units)
|
||||
|
||||
**Appearance** (text input + selection):
|
||||
- theme (selection from available themes)
|
||||
- font_size (numeric input)
|
||||
- width, height (numeric input)
|
||||
- border_radius (numeric input)
|
||||
|
||||
**Search** (text input + selection):
|
||||
- search_engine (selection: google, duckduckgo, bing, startpage, brave, ecosia)
|
||||
- frecency (boolean toggle)
|
||||
- frecency_weight (numeric input, 0.0–1.0)
|
||||
|
||||
**Profiles** (CRUD):
|
||||
- List existing profiles
|
||||
- Create new profile (name input + mode checklist)
|
||||
- Edit profile (rename, edit modes, delete)
|
||||
|
||||
### Not in scope
|
||||
|
||||
- Weather API key / location (sensitive, better in config file)
|
||||
- Pomodoro durations (niche, config file)
|
||||
- Plugin disabled list (covered by provider toggles)
|
||||
- use_uwsm / terminal_command (advanced, config file)
|
||||
|
||||
## UX Flow
|
||||
|
||||
### Entry point
|
||||
|
||||
Type `:config` or select the "Settings" item that appears for queries like "settings", "config", "preferences".
|
||||
|
||||
### Top-level categories
|
||||
|
||||
```
|
||||
:config →
|
||||
┌─ Providers Toggle providers on/off
|
||||
├─ Appearance Theme, font size, dimensions
|
||||
├─ Search Search engine, frecency
|
||||
└─ Profiles Manage named mode sets
|
||||
```
|
||||
|
||||
Each category is a submenu item. Selecting one opens its submenu.
|
||||
|
||||
### Provider toggles
|
||||
|
||||
```
|
||||
Providers →
|
||||
┌─ ✓ Applications [toggle]
|
||||
├─ ✓ Commands [toggle]
|
||||
├─ ✓ Calculator [toggle]
|
||||
├─ ✓ Converter [toggle]
|
||||
├─ ✓ System [toggle]
|
||||
├─ ✗ Weather [toggle]
|
||||
├─ ...
|
||||
```
|
||||
|
||||
Selecting a row toggles it. The ✓/✗ prefix updates immediately. Change is written to `config.toml` and hot-applied where possible.
|
||||
|
||||
### Appearance settings
|
||||
|
||||
```
|
||||
Appearance →
|
||||
┌─ Theme: owl [select]
|
||||
├─ Font Size: 14 [edit]
|
||||
├─ Width: 850 [edit]
|
||||
├─ Height: 650 [edit]
|
||||
└─ Border Radius: 12 [edit]
|
||||
```
|
||||
|
||||
**Selection fields** (theme): Selecting opens a submenu with available options. Current value is marked with ✓.
|
||||
|
||||
**Text/numeric fields** (font size, width, etc.): Selecting a row enters edit mode — the search bar clears and shows a placeholder like "Font Size (current: 14)". User types a new value and presses Enter. The value is validated (numeric, within reasonable range), written to config, and the submenu re-displays with the updated value.
|
||||
|
||||
### Search settings
|
||||
|
||||
```
|
||||
Search →
|
||||
┌─ Search Engine: duckduckgo [select]
|
||||
├─ Frecency: enabled [toggle]
|
||||
└─ Frecency Weight: 0.3 [edit]
|
||||
```
|
||||
|
||||
Same patterns — selection for engine, toggle for frecency, text input for weight.
|
||||
|
||||
### Profile management
|
||||
|
||||
```
|
||||
Profiles →
|
||||
┌─ dev (app, cmd, ssh) [submenu]
|
||||
├─ media (media, emoji) [submenu]
|
||||
└─ ➕ Create New Profile [action]
|
||||
```
|
||||
|
||||
**Select existing profile** → submenu:
|
||||
```
|
||||
Profile: dev →
|
||||
┌─ Edit Modes [submenu → checklist]
|
||||
├─ Rename [text input]
|
||||
└─ Delete [confirm action]
|
||||
```
|
||||
|
||||
**Edit Modes** → checklist (same as provider toggles but for the profile's mode list):
|
||||
```
|
||||
Edit Modes: dev →
|
||||
┌─ ✓ app
|
||||
├─ ✓ cmd
|
||||
├─ ✗ calc
|
||||
├─ ✗ conv
|
||||
├─ ✓ ssh
|
||||
├─ ...
|
||||
```
|
||||
|
||||
Toggle to include/exclude. Changes saved on submenu exit (Escape).
|
||||
|
||||
**Create New Profile**:
|
||||
1. Search bar becomes name input (placeholder: "Profile name...")
|
||||
2. User types name, presses Enter
|
||||
3. Opens mode checklist (all unchecked)
|
||||
4. Toggle desired modes, press Escape to save
|
||||
|
||||
**Delete**: Selecting "Delete" removes the profile from config and returns to the profiles list.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Provider type
|
||||
|
||||
Built-in static provider in owlry-core. Uses `ProviderType::Plugin("config")` with prefix `:config`.
|
||||
|
||||
### Provider classification
|
||||
|
||||
**Static** — the top-level items (Providers, Appearance, Search, Profiles) are populated at refresh time. But it also needs **submenu support** — each category opens a submenu with actions.
|
||||
|
||||
This means the config provider needs to handle `?SUBMENU:` queries to generate submenu items dynamically, and `!ACTION:` commands to execute changes.
|
||||
|
||||
### Command protocol
|
||||
|
||||
Actions use the existing plugin action system (`PluginAction` IPC request):
|
||||
|
||||
- `CONFIG:toggle:providers.calculator` — toggle a boolean
|
||||
- `CONFIG:set:appearance.font_size:16` — set a value
|
||||
- `CONFIG:set:providers.search_engine:google` — set a string
|
||||
- `CONFIG:profile:create:dev` — create a profile
|
||||
- `CONFIG:profile:delete:dev` — delete a profile
|
||||
- `CONFIG:profile:rename:dev:development` — rename
|
||||
- `CONFIG:profile:mode:dev:toggle:ssh` — toggle a mode in a profile
|
||||
|
||||
### Config persistence
|
||||
|
||||
All changes write to `~/.config/owlry/config.toml` via the existing `Config::save()` method.
|
||||
|
||||
### Hot-apply behavior
|
||||
|
||||
| Setting | Hot-apply | Notes |
|
||||
|---------|-----------|-------|
|
||||
| Provider toggles | Yes | Daemon re-reads config, enables/disables providers |
|
||||
| Theme | Yes | UI reloads CSS |
|
||||
| Frecency toggle/weight | Yes | Next search uses new value |
|
||||
| Search engine | Yes | Next web search uses new engine |
|
||||
| Font size | Restart | CSS variable, needs reload |
|
||||
| Width/Height | Restart | GTK window geometry set at construction |
|
||||
| Border radius | Restart | CSS variable, needs reload |
|
||||
| Profiles | Yes | Config file update, available on next `--profile` launch |
|
||||
|
||||
Settings that require restart show a "(restart to apply)" hint in the description.
|
||||
|
||||
### Submenu integration
|
||||
|
||||
The config provider uses the existing submenu system:
|
||||
- Top-level items have `SUBMENU:config:{category}` commands
|
||||
- Categories return action items via `?SUBMENU:{category}`
|
||||
- Actions execute via `CONFIG:*` commands through `execute_plugin_action`
|
||||
|
||||
This keeps the implementation within the existing provider/submenu architecture without new IPC message types.
|
||||
580
justfile
580
justfile
@@ -1,65 +1,57 @@
|
||||
# Owlry build and release automation
|
||||
|
||||
# Default recipe
|
||||
default:
|
||||
@just --list
|
||||
|
||||
# Build debug (all workspace members)
|
||||
# === Build ===
|
||||
|
||||
build:
|
||||
cargo build --workspace
|
||||
|
||||
# Build UI binary only
|
||||
build-ui:
|
||||
cargo build -p owlry
|
||||
|
||||
# Build core daemon only
|
||||
build-daemon:
|
||||
cargo build -p owlry-core
|
||||
|
||||
# Build core daemon release
|
||||
release-daemon:
|
||||
cargo build -p owlry-core --release
|
||||
|
||||
# Run core daemon
|
||||
run-daemon *ARGS:
|
||||
cargo run -p owlry-core -- {{ARGS}}
|
||||
|
||||
# Build release
|
||||
release:
|
||||
cargo build --workspace --release
|
||||
|
||||
# Run in debug mode
|
||||
release-daemon:
|
||||
cargo build -p owlry-core --release
|
||||
|
||||
# === Run ===
|
||||
|
||||
run *ARGS:
|
||||
cargo run -p owlry -- {{ARGS}}
|
||||
|
||||
# Run tests
|
||||
run-daemon *ARGS:
|
||||
cargo run -p owlry-core -- {{ARGS}}
|
||||
|
||||
# === Quality ===
|
||||
|
||||
test:
|
||||
cargo test --workspace
|
||||
|
||||
# Check code
|
||||
check:
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace
|
||||
|
||||
# Format code
|
||||
fmt:
|
||||
cargo fmt --all
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
cargo clean
|
||||
|
||||
# Install locally (core + runtimes)
|
||||
# === Install ===
|
||||
|
||||
install-local:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "Building release..."
|
||||
# Build UI without embedded Lua (smaller binary)
|
||||
cargo build -p owlry --release --no-default-features
|
||||
# Build core daemon
|
||||
cargo build -p owlry-core --release
|
||||
# Build runtimes
|
||||
cargo build -p owlry-lua -p owlry-rune --release
|
||||
|
||||
echo "Creating directories..."
|
||||
@@ -68,58 +60,24 @@ install-local:
|
||||
|
||||
echo "Installing binaries..."
|
||||
sudo install -Dm755 target/release/owlry /usr/bin/owlry
|
||||
sudo install -Dm755 target/release/owlry-core /usr/bin/owlry-core
|
||||
sudo install -Dm755 target/release/owlryd /usr/bin/owlryd
|
||||
|
||||
echo "Installing runtimes..."
|
||||
if [ -f "target/release/libowlry_lua.so" ]; then
|
||||
sudo install -Dm755 target/release/libowlry_lua.so /usr/lib/owlry/runtimes/liblua.so
|
||||
echo " → liblua.so"
|
||||
fi
|
||||
if [ -f "target/release/libowlry_rune.so" ]; then
|
||||
sudo install -Dm755 target/release/libowlry_rune.so /usr/lib/owlry/runtimes/librune.so
|
||||
echo " → librune.so"
|
||||
fi
|
||||
[ -f target/release/libowlry_lua.so ] && sudo install -Dm755 target/release/libowlry_lua.so /usr/lib/owlry/runtimes/liblua.so
|
||||
[ -f target/release/libowlry_rune.so ] && sudo install -Dm755 target/release/libowlry_rune.so /usr/lib/owlry/runtimes/librune.so
|
||||
|
||||
echo "Installing systemd service files..."
|
||||
if [ -f "systemd/owlry-core.service" ]; then
|
||||
sudo install -Dm644 systemd/owlry-core.service /usr/lib/systemd/user/owlry-core.service
|
||||
echo " → owlry-core.service"
|
||||
fi
|
||||
if [ -f "systemd/owlry-core.socket" ]; then
|
||||
sudo install -Dm644 systemd/owlry-core.socket /usr/lib/systemd/user/owlry-core.socket
|
||||
echo " → owlry-core.socket"
|
||||
fi
|
||||
[ -f systemd/owlryd.service ] && sudo install -Dm644 systemd/owlryd.service /usr/lib/systemd/user/owlryd.service
|
||||
[ -f systemd/owlryd.socket ] && sudo install -Dm644 systemd/owlryd.socket /usr/lib/systemd/user/owlryd.socket
|
||||
|
||||
echo ""
|
||||
echo "Installation complete!"
|
||||
echo " - /usr/bin/owlry (UI)"
|
||||
echo " - /usr/bin/owlry-core (daemon)"
|
||||
echo " - $(ls /usr/lib/owlry/runtimes/*.so 2>/dev/null | wc -l) runtimes"
|
||||
echo " - systemd: owlry-core.service, owlry-core.socket"
|
||||
echo ""
|
||||
echo "To start the daemon:"
|
||||
echo " systemctl --user enable --now owlry-core.service"
|
||||
echo " OR add 'exec-once = owlry-core' to your compositor config"
|
||||
echo ""
|
||||
echo "Note: Install plugins separately from the owlry-plugins repo."
|
||||
echo "Done. Start daemon: systemctl --user enable --now owlryd.service"
|
||||
|
||||
# === Release Management ===
|
||||
# === Version Management ===
|
||||
|
||||
# AUR package directories (relative to project root)
|
||||
aur_core_dir := "aur/owlry"
|
||||
|
||||
# Get current version from core crate
|
||||
version := `grep '^version' crates/owlry/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/'`
|
||||
|
||||
# Show current version
|
||||
show-version:
|
||||
@echo "Current version: {{version}}"
|
||||
|
||||
# Show all crate versions
|
||||
show-versions:
|
||||
#!/usr/bin/env bash
|
||||
echo "=== Crate Versions ==="
|
||||
for toml in Cargo.toml crates/*/Cargo.toml; do
|
||||
for toml in crates/*/Cargo.toml; do
|
||||
name=$(grep '^name' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||
ver=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||
printf " %-30s %s\n" "$name" "$ver"
|
||||
@@ -129,20 +87,16 @@ show-versions:
|
||||
crate-version crate:
|
||||
@grep '^version' crates/{{crate}}/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/'
|
||||
|
||||
# Bump a specific crate version (usage: just bump-crate owlry-core 0.2.0)
|
||||
# Bump a single crate version, update Cargo.lock, commit
|
||||
bump-crate crate new_version:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
toml="crates/{{crate}}/Cargo.toml"
|
||||
if [ ! -f "$toml" ]; then
|
||||
echo "Error: $toml not found"
|
||||
exit 1
|
||||
fi
|
||||
[ -f "$toml" ] || { echo "Error: $toml not found"; exit 1; }
|
||||
|
||||
old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||
if [ "$old" = "{{new_version}}" ]; then
|
||||
echo "{{crate}} is already at {{new_version}}, skipping"
|
||||
exit 0
|
||||
fi
|
||||
[ "$old" = "{{new_version}}" ] && { echo "{{crate}} already at {{new_version}}"; exit 0; }
|
||||
|
||||
echo "Bumping {{crate}} from $old to {{new_version}}"
|
||||
sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml"
|
||||
cargo check -p {{crate}}
|
||||
@@ -150,7 +104,215 @@ bump-crate crate new_version:
|
||||
git commit -m "chore({{crate}}): bump version to {{new_version}}"
|
||||
echo "{{crate}} bumped to {{new_version}}"
|
||||
|
||||
# Bump meta-packages (no crate, just AUR version)
|
||||
# Bump all crates to same version
|
||||
bump-all new_version:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
for toml in crates/*/Cargo.toml; do
|
||||
crate=$(basename $(dirname "$toml"))
|
||||
old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||
[ "$old" = "{{new_version}}" ] && continue
|
||||
echo "Bumping $crate from $old to {{new_version}}"
|
||||
sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml"
|
||||
done
|
||||
cargo check --workspace
|
||||
git add crates/*/Cargo.toml Cargo.lock
|
||||
git commit -m "chore: bump all crates to {{new_version}}"
|
||||
echo "All crates bumped to {{new_version}}"
|
||||
|
||||
# Bump core UI only
|
||||
bump new_version:
|
||||
just bump-crate owlry {{new_version}}
|
||||
|
||||
# === Tagging ===
|
||||
|
||||
# Tag a specific crate (format: {crate}-v{version})
|
||||
tag-crate crate:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ver=$(grep '^version' "crates/{{crate}}/Cargo.toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||
tag="{{crate}}-v$ver"
|
||||
if git rev-parse "$tag" >/dev/null 2>&1; then
|
||||
echo "Tag $tag already exists"
|
||||
exit 0
|
||||
fi
|
||||
git tag -a "$tag" -m "{{crate}} v$ver"
|
||||
echo "Created tag $tag"
|
||||
|
||||
# Push all local tags
|
||||
push-tags:
|
||||
git push --tags
|
||||
|
||||
# === AUR Package Management ===
|
||||
|
||||
# Stage AUR files into the main repo git index.
|
||||
# AUR subdirs have their own .git (for aur.archlinux.org), which makes
|
||||
# git treat them as embedded repos. Temporarily hide .git to stage files.
|
||||
aur-stage pkg:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
dir="aur/{{pkg}}"
|
||||
[ -d "$dir" ] || { echo "Error: $dir not found"; exit 1; }
|
||||
|
||||
# Build list of files to stage
|
||||
files=("$dir/PKGBUILD" "$dir/.SRCINFO")
|
||||
for f in "$dir"/*.install; do
|
||||
[ -f "$f" ] && files+=("$f")
|
||||
done
|
||||
|
||||
if [ -d "$dir/.git" ]; then
|
||||
mv "$dir/.git" "$dir/.git.bak"
|
||||
git add "${files[@]}"
|
||||
mv "$dir/.git.bak" "$dir/.git"
|
||||
else
|
||||
git add "${files[@]}"
|
||||
fi
|
||||
|
||||
# Update a specific AUR package PKGBUILD with correct version + checksum
|
||||
aur-update-pkg pkg:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
aur_dir="aur/{{pkg}}"
|
||||
[ -d "$aur_dir" ] || { echo "Error: $aur_dir not found"; exit 1; }
|
||||
|
||||
# Determine version
|
||||
case "{{pkg}}" in
|
||||
owlry-meta-*)
|
||||
ver=$(grep '^pkgver=' "$aur_dir/PKGBUILD" | sed 's/pkgver=//')
|
||||
echo "Meta-package {{pkg}} at $ver (bump pkgrel manually if needed)"
|
||||
(cd "$aur_dir" && makepkg --printsrcinfo > .SRCINFO)
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
crate_dir="crates/{{pkg}}"
|
||||
[ -d "$crate_dir" ] || { echo "Error: $crate_dir not found"; exit 1; }
|
||||
ver=$(grep '^version' "$crate_dir/Cargo.toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||
;;
|
||||
esac
|
||||
|
||||
tag="{{pkg}}-v$ver"
|
||||
url="https://somegit.dev/Owlibou/owlry/archive/$tag.tar.gz"
|
||||
|
||||
echo "Updating {{pkg}} to $ver (tag: $tag)"
|
||||
sed -i "s/^pkgver=.*/pkgver=$ver/" "$aur_dir/PKGBUILD"
|
||||
sed -i 's/^pkgrel=.*/pkgrel=1/' "$aur_dir/PKGBUILD"
|
||||
|
||||
# Update checksum from the tagged tarball
|
||||
if grep -q "^source=" "$aur_dir/PKGBUILD"; then
|
||||
echo "Downloading tarball and computing checksum..."
|
||||
hash=$(curl -sL "$url" | b2sum | cut -d' ' -f1)
|
||||
if [ -z "$hash" ] || [ ${#hash} -lt 64 ]; then
|
||||
echo "Error: failed to download or hash $url"
|
||||
exit 1
|
||||
fi
|
||||
sed -i "s|^b2sums=.*|b2sums=('$hash')|" "$aur_dir/PKGBUILD"
|
||||
fi
|
||||
|
||||
(cd "$aur_dir" && makepkg --printsrcinfo > .SRCINFO)
|
||||
echo "{{pkg}} PKGBUILD updated to $ver"
|
||||
|
||||
# Shortcut: update core UI AUR package
|
||||
aur-update:
|
||||
just aur-update-pkg owlry
|
||||
|
||||
# Publish a specific AUR package to aur.archlinux.org
|
||||
aur-publish-pkg pkg:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
aur_dir="aur/{{pkg}}"
|
||||
[ -d "$aur_dir/.git" ] || { echo "Error: $aur_dir has no AUR git repo"; exit 1; }
|
||||
|
||||
cd "$aur_dir"
|
||||
ver=$(grep '^pkgver=' PKGBUILD | sed 's/pkgver=//')
|
||||
git add -A
|
||||
git commit -m "Update to v$ver" || { echo "Nothing to commit"; exit 0; }
|
||||
git push origin master
|
||||
echo "{{pkg}} v$ver published to AUR!"
|
||||
|
||||
# Shortcut: publish core UI to AUR
|
||||
aur-publish:
|
||||
just aur-publish-pkg owlry
|
||||
|
||||
# Update and publish ALL AUR packages
|
||||
aur-update-all:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
for dir in aur/*/; do
|
||||
pkg=$(basename "$dir")
|
||||
[ -f "$dir/PKGBUILD" ] || continue
|
||||
echo "=== $pkg ==="
|
||||
just aur-update-pkg "$pkg"
|
||||
echo ""
|
||||
done
|
||||
echo "All updated. Run 'just aur-publish-all' to publish."
|
||||
|
||||
aur-publish-all:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
for dir in aur/*/; do
|
||||
pkg=$(basename "$dir")
|
||||
[ -d "$dir/.git" ] || continue
|
||||
[ -f "$dir/PKGBUILD" ] || continue
|
||||
echo "=== $pkg ==="
|
||||
just aur-publish-pkg "$pkg"
|
||||
echo ""
|
||||
done
|
||||
echo "All published!"
|
||||
|
||||
# Show AUR package status
|
||||
aur-status:
|
||||
#!/usr/bin/env bash
|
||||
echo "=== AUR Package Status ==="
|
||||
for dir in aur/*/; do
|
||||
pkg=$(basename "$dir")
|
||||
[ -f "$dir/PKGBUILD" ] || continue
|
||||
ver=$(grep '^pkgver=' "$dir/PKGBUILD" | sed 's/pkgver=//')
|
||||
if [ -d "$dir/.git" ]; then
|
||||
printf " ✓ %-30s %s\n" "$pkg" "$ver"
|
||||
else
|
||||
printf " ✗ %-30s %s (no AUR repo)\n" "$pkg" "$ver"
|
||||
fi
|
||||
done
|
||||
|
||||
# Commit AUR file changes to the main repo (handles embedded .git dirs)
|
||||
aur-commit msg="chore(aur): update PKGBUILDs":
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
for dir in aur/*/; do
|
||||
pkg=$(basename "$dir")
|
||||
[ -f "$dir/PKGBUILD" ] || continue
|
||||
just aur-stage "$pkg"
|
||||
done
|
||||
git diff --cached --quiet && { echo "No AUR changes to commit"; exit 0; }
|
||||
git commit -m "{{msg}}"
|
||||
|
||||
# === Release Workflows ===
|
||||
|
||||
# Release a single crate: bump → push → tag → update AUR → publish AUR
|
||||
release-crate crate new_version:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
just bump-crate {{crate}} {{new_version}}
|
||||
git push
|
||||
|
||||
just tag-crate {{crate}}
|
||||
just push-tags
|
||||
|
||||
echo "Waiting for tag to propagate..."
|
||||
sleep 3
|
||||
|
||||
just aur-update-pkg {{crate}}
|
||||
just aur-commit "chore(aur): update {{crate}} to {{new_version}}"
|
||||
git push
|
||||
|
||||
just aur-publish-pkg {{crate}}
|
||||
echo ""
|
||||
echo "{{crate}} v{{new_version}} released and published to AUR!"
|
||||
|
||||
# === Meta Package Management ===
|
||||
|
||||
# Bump meta-package versions
|
||||
bump-meta new_version:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
@@ -165,271 +327,25 @@ bump-meta new_version:
|
||||
done
|
||||
echo "Meta-packages bumped to {{new_version}}"
|
||||
|
||||
# Bump all crates (core UI + daemon + plugin-api + runtimes) to same version
|
||||
bump-all new_version:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
for toml in crates/*/Cargo.toml; do
|
||||
crate=$(basename $(dirname "$toml"))
|
||||
old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||
if [ "$old" != "{{new_version}}" ]; then
|
||||
echo "Bumping $crate from $old to {{new_version}}"
|
||||
sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml"
|
||||
fi
|
||||
done
|
||||
cargo check --workspace
|
||||
git add crates/*/Cargo.toml Cargo.lock
|
||||
git commit -m "chore: bump all crates to {{new_version}}"
|
||||
echo "All crates bumped to {{new_version}}"
|
||||
# === Testing ===
|
||||
|
||||
# Bump core version (usage: just bump 0.2.0)
|
||||
bump new_version:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [ "{{version}}" = "{{new_version}}" ]; then
|
||||
echo "Version is already {{new_version}}, skipping bump"
|
||||
exit 0
|
||||
fi
|
||||
echo "Bumping core version from {{version}} to {{new_version}}"
|
||||
sed -i 's/^version = ".*"/version = "{{new_version}}"/' crates/owlry/Cargo.toml
|
||||
cargo check -p owlry
|
||||
git add crates/owlry/Cargo.toml Cargo.lock
|
||||
git commit -m "chore: bump version to {{new_version}}"
|
||||
echo "Version bumped to {{new_version}}"
|
||||
|
||||
# Create and push a release tag
|
||||
tag:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if git rev-parse "v{{version}}" >/dev/null 2>&1; then
|
||||
echo "Tag v{{version}} already exists, skipping"
|
||||
exit 0
|
||||
fi
|
||||
echo "Creating tag v{{version}}"
|
||||
git tag -a "v{{version}}" -m "Release v{{version}}"
|
||||
git push origin "v{{version}}"
|
||||
echo "Tag v{{version}} pushed"
|
||||
|
||||
# Update AUR package (core UI)
|
||||
aur-update:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "{{aur_core_dir}}"
|
||||
|
||||
url="https://somegit.dev/Owlibou/owlry"
|
||||
|
||||
echo "Updating PKGBUILD to version {{version}}"
|
||||
sed -i 's/^pkgver=.*/pkgver={{version}}/' PKGBUILD
|
||||
sed -i 's/^pkgrel=.*/pkgrel=1/' PKGBUILD
|
||||
|
||||
# Update checksums (b2sums)
|
||||
echo "Updating checksums..."
|
||||
b2sum=$(curl -sL "$url/archive/v{{version}}.tar.gz" | b2sum | cut -d' ' -f1)
|
||||
sed -i "s/^b2sums=.*/b2sums=('$b2sum')/" PKGBUILD
|
||||
|
||||
# Generate .SRCINFO
|
||||
echo "Generating .SRCINFO..."
|
||||
makepkg --printsrcinfo > .SRCINFO
|
||||
|
||||
# Show diff
|
||||
git diff
|
||||
|
||||
echo ""
|
||||
echo "AUR package updated. Review changes above."
|
||||
echo "Run 'just aur-publish' to commit and push."
|
||||
|
||||
# Publish AUR package (core UI)
|
||||
aur-publish:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "{{aur_core_dir}}"
|
||||
|
||||
git add PKGBUILD .SRCINFO
|
||||
git commit -m "Update to v{{version}}"
|
||||
git push
|
||||
|
||||
echo "AUR package v{{version}} published!"
|
||||
|
||||
# Test AUR package build locally (core UI)
|
||||
aur-test:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "{{aur_core_dir}}"
|
||||
|
||||
echo "Testing PKGBUILD..."
|
||||
makepkg -sf
|
||||
|
||||
echo ""
|
||||
echo "Package built successfully!"
|
||||
ls -lh *.pkg.tar.zst
|
||||
|
||||
# === AUR Package Management (individual packages) ===
|
||||
|
||||
# Update a specific AUR package (usage: just aur-update-pkg owlry-core)
|
||||
aur-update-pkg pkg:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
aur_dir="aur/{{pkg}}"
|
||||
|
||||
if [ ! -d "$aur_dir" ]; then
|
||||
echo "Error: $aur_dir not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
url="https://somegit.dev/Owlibou/owlry"
|
||||
|
||||
# Determine crate version
|
||||
case "{{pkg}}" in
|
||||
owlry-meta-essentials|owlry-meta-tools|owlry-meta-widgets|owlry-meta-full)
|
||||
# Meta-packages use static versioning (1.0.0), only bump pkgrel for dep changes
|
||||
crate_ver=$(grep '^pkgver=' "$aur_dir/PKGBUILD" | sed 's/pkgver=//')
|
||||
;;
|
||||
*)
|
||||
# Get version from crate
|
||||
crate_dir="crates/{{pkg}}"
|
||||
if [ ! -d "$crate_dir" ]; then
|
||||
echo "Error: $crate_dir not found"
|
||||
exit 1
|
||||
fi
|
||||
crate_ver=$(grep '^version' "$crate_dir/Cargo.toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||
;;
|
||||
esac
|
||||
|
||||
cd "$aur_dir"
|
||||
|
||||
echo "Updating {{pkg}} PKGBUILD:"
|
||||
echo " pkgver=$crate_ver"
|
||||
|
||||
sed -i "s/^pkgver=.*/pkgver=$crate_ver/" PKGBUILD
|
||||
sed -i 's/^pkgrel=.*/pkgrel=1/' PKGBUILD
|
||||
|
||||
# Update checksums
|
||||
if grep -q "^source=" PKGBUILD; then
|
||||
echo "Updating checksums..."
|
||||
b2sum=$(curl -sL "$url/archive/v$crate_ver.tar.gz" | b2sum | cut -d' ' -f1)
|
||||
sed -i "s/^b2sums=.*/b2sums=('$b2sum')/" PKGBUILD
|
||||
fi
|
||||
|
||||
# Generate .SRCINFO
|
||||
echo "Generating .SRCINFO..."
|
||||
makepkg --printsrcinfo > .SRCINFO
|
||||
|
||||
git diff --stat
|
||||
echo ""
|
||||
echo "{{pkg}} updated. Run 'just aur-publish-pkg {{pkg}}' to publish."
|
||||
|
||||
# Publish a specific AUR package
|
||||
aur-publish-pkg pkg:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
aur_dir="aur/{{pkg}}"
|
||||
|
||||
if [ ! -d "$aur_dir" ]; then
|
||||
echo "Error: $aur_dir not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$aur_dir"
|
||||
ver=$(grep '^pkgver=' PKGBUILD | sed 's/pkgver=//')
|
||||
|
||||
git add PKGBUILD .SRCINFO
|
||||
git commit -m "Update to v$ver"
|
||||
git push origin master
|
||||
|
||||
echo "{{pkg}} v$ver published!"
|
||||
|
||||
# Test a specific AUR package build locally
|
||||
# Quick local build test (no chroot, uses host deps)
|
||||
aur-test-pkg pkg:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "aur/{{pkg}}"
|
||||
|
||||
echo "Testing {{pkg}} PKGBUILD..."
|
||||
makepkg -sf
|
||||
|
||||
echo ""
|
||||
echo "Package built successfully!"
|
||||
ls -lh *.pkg.tar.zst
|
||||
|
||||
# List all AUR packages with their versions
|
||||
aur-status:
|
||||
#!/usr/bin/env bash
|
||||
echo "=== AUR Package Status ==="
|
||||
for dir in aur/*/; do
|
||||
pkg=$(basename "$dir")
|
||||
if [ -f "$dir/PKGBUILD" ]; then
|
||||
ver=$(grep '^pkgver=' "$dir/PKGBUILD" | sed 's/pkgver=//')
|
||||
if [ -d "$dir/.git" ]; then
|
||||
status="✓"
|
||||
else
|
||||
status="✗ (not initialized)"
|
||||
fi
|
||||
printf " %s %-30s %s\n" "$status" "$pkg" "$ver"
|
||||
fi
|
||||
done
|
||||
|
||||
# Update ALL AUR packages (core + daemon + runtimes + meta)
|
||||
aur-update-all:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
echo "=== Updating core UI ==="
|
||||
just aur-update
|
||||
echo ""
|
||||
echo "=== Updating core daemon ==="
|
||||
just aur-update-pkg owlry-core
|
||||
echo ""
|
||||
echo "=== Updating runtimes ==="
|
||||
just aur-update-pkg owlry-lua
|
||||
just aur-update-pkg owlry-rune
|
||||
echo ""
|
||||
echo "=== Updating meta-packages ==="
|
||||
for pkg in owlry-meta-essentials owlry-meta-tools owlry-meta-widgets owlry-meta-full; do
|
||||
echo "--- $pkg ---"
|
||||
(cd "aur/$pkg" && makepkg --printsrcinfo > .SRCINFO)
|
||||
done
|
||||
echo ""
|
||||
echo "All AUR packages updated. Run 'just aur-publish-all' to publish."
|
||||
|
||||
# Publish ALL AUR packages
|
||||
aur-publish-all:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
echo "=== Publishing core UI ==="
|
||||
just aur-publish
|
||||
echo ""
|
||||
echo "=== Publishing core daemon ==="
|
||||
just aur-publish-pkg owlry-core
|
||||
echo ""
|
||||
echo "=== Publishing runtimes ==="
|
||||
just aur-publish-pkg owlry-lua
|
||||
just aur-publish-pkg owlry-rune
|
||||
echo ""
|
||||
echo "=== Publishing meta-packages ==="
|
||||
for pkg in owlry-meta-essentials owlry-meta-tools owlry-meta-widgets owlry-meta-full; do
|
||||
echo "--- $pkg ---"
|
||||
just aur-publish-pkg "$pkg"
|
||||
done
|
||||
echo ""
|
||||
echo "All AUR packages published!"
|
||||
|
||||
# Full release workflow for core only (bump + tag + aur)
|
||||
release-core new_version: (bump new_version)
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Push version bump
|
||||
git push
|
||||
|
||||
# Create and push tag
|
||||
just tag
|
||||
|
||||
# Wait for tag to be available
|
||||
echo "Waiting for tag to propagate..."
|
||||
sleep 2
|
||||
|
||||
# Update AUR
|
||||
just aur-update
|
||||
|
||||
echo ""
|
||||
echo "Core release v{{new_version}} prepared!"
|
||||
echo "Review AUR changes, then run 'just aur-publish'"
|
||||
# Build AUR packages from the local working tree in a clean chroot.
|
||||
# Packages current source (incl. uncommitted changes), patches PKGBUILD,
|
||||
# builds in dep order, injects local artifacts, restores PKGBUILD on exit.
|
||||
#
|
||||
# Examples:
|
||||
# just aur-local-test owlry-core
|
||||
# just aur-local-test -c owlry-core owlry-rune
|
||||
# just aur-local-test --all --reset
|
||||
aur-local-test *args:
|
||||
scripts/aur-local-test {{args}}
|
||||
|
||||
373
scripts/aur-local-test
Executable file
373
scripts/aur-local-test
Executable file
@@ -0,0 +1,373 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/aur-local-test
|
||||
#
|
||||
# Build AUR packages from the local working tree in a clean extra chroot.
|
||||
#
|
||||
# Packages the current working tree (including uncommitted changes) into a
|
||||
# tarball, temporarily patches each PKGBUILD to use it, runs
|
||||
# extra-x86_64-build, then restores the PKGBUILD on exit regardless of
|
||||
# success or failure.
|
||||
#
|
||||
# Packages with local AUR deps (e.g. owlry-rune depends on owlry-core) are
|
||||
# built in topological order and their artifacts injected automatically.
|
||||
#
|
||||
# Usage: scripts/aur-local-test [OPTIONS] [PKG...]
|
||||
# See --help for details.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)"
|
||||
REPO_NAME="$(basename "$REPO_ROOT")"
|
||||
AUR_DIR="$REPO_ROOT/aur"
|
||||
|
||||
# State tracked for cleanup
|
||||
TMP_TARBALL=""
|
||||
declare -a PKGBUILD_BACKUPS=()
|
||||
declare -a PLACED_FILES=()
|
||||
|
||||
# Build config
|
||||
RESET_CHROOT=0
|
||||
declare -a INPUT_PKGS=()
|
||||
declare -a EXTRA_INJECT=() # --inject paths (external AUR deps)
|
||||
|
||||
# ─── Output helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
die() { echo "error: $*" >&2; exit 1; }
|
||||
info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
|
||||
ok() { printf '\033[1;32m ->\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33m !\033[0m %s\n' "$*" >&2; }
|
||||
fail() { printf '\033[1;31mFAIL\033[0m %s\n' "$*" >&2; }
|
||||
|
||||
# ─── Cleanup ─────────────────────────────────────────────────────────────────
|
||||
|
||||
cleanup() {
|
||||
local code=$?
|
||||
local f pkgbuild
|
||||
|
||||
# Remove tarballs placed in aur/ dirs
|
||||
for f in "${PLACED_FILES[@]+"${PLACED_FILES[@]}"}"; do
|
||||
[[ -f "$f" ]] && rm -f "$f"
|
||||
done
|
||||
|
||||
# Restore patched PKGBUILDs from backups
|
||||
for f in "${PKGBUILD_BACKUPS[@]+"${PKGBUILD_BACKUPS[@]}"}"; do
|
||||
pkgbuild="${f%.bak}"
|
||||
[[ -f "$f" ]] && mv "$f" "$pkgbuild"
|
||||
done
|
||||
|
||||
[[ -n "$TMP_TARBALL" && -f "$TMP_TARBALL" ]] && rm -f "$TMP_TARBALL"
|
||||
|
||||
exit "$code"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
# ─── Usage ───────────────────────────────────────────────────────────────────
|
||||
|
||||
usage() {
|
||||
cat >&2 <<EOF
|
||||
Usage: $(basename "$0") [OPTIONS] [PKG...]
|
||||
|
||||
Build AUR packages from the local working tree in a clean chroot.
|
||||
Packages current working tree (incl. uncommitted changes), patches PKGBUILD
|
||||
source + checksum, runs extra-x86_64-build, then restores on exit.
|
||||
|
||||
Packages with local AUR deps are built in topological order and their
|
||||
.pkg.tar.zst artifacts are injected into dependent builds automatically.
|
||||
|
||||
OPTIONS
|
||||
-c, --reset Reset chroot matrix (passes -c to extra-x86_64-build).
|
||||
Only applied to the first package; subsequent builds
|
||||
reuse the already-fresh chroot.
|
||||
-a, --all Build all packages in aur/ (respects dep order).
|
||||
-I, --inject FILE Inject FILE (.pkg.tar.zst) into the chroot before
|
||||
building. For AUR deps not in the official repos
|
||||
(e.g. owlry-core when testing owlry-plugins).
|
||||
Can be repeated.
|
||||
-h, --help Show this help.
|
||||
|
||||
EXAMPLES
|
||||
# Single package
|
||||
$(basename "$0") owlry-core
|
||||
|
||||
# Multiple packages with chroot reset
|
||||
$(basename "$0") -c owlry-core owlry-rune
|
||||
|
||||
# All packages in dependency order
|
||||
$(basename "$0") --all --reset
|
||||
|
||||
# owlry-plugins: inject owlry-core from sibling repo
|
||||
$(basename "$0") -I ../owlry/aur/owlry-core/owlry-core-*.pkg.tar.zst --all
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ─── Argument parsing ────────────────────────────────────────────────────────
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-c|--reset)
|
||||
RESET_CHROOT=1
|
||||
shift ;;
|
||||
-a|--all)
|
||||
for dir in "$AUR_DIR"/*/; do
|
||||
pkg=$(basename "$dir")
|
||||
[[ -f "$dir/PKGBUILD" ]] && INPUT_PKGS+=("$pkg")
|
||||
done
|
||||
shift ;;
|
||||
-I|--inject)
|
||||
[[ $# -ge 2 ]] || die "--inject requires an argument"
|
||||
[[ -f "$2" ]] || die "inject file not found: $2"
|
||||
EXTRA_INJECT+=("$(realpath "$2")")
|
||||
shift 2 ;;
|
||||
-h|--help) usage ;;
|
||||
-*) die "unknown option: $1" ;;
|
||||
*)
|
||||
if [[ "$1" == *.pkg.tar.zst ]]; then
|
||||
[[ -f "$1" ]] || die "inject file not found: $1"
|
||||
EXTRA_INJECT+=("$(realpath "$1")")
|
||||
else
|
||||
INPUT_PKGS+=("$1")
|
||||
fi
|
||||
shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ ${#INPUT_PKGS[@]} -eq 0 ]] && usage
|
||||
|
||||
# ─── Inject deduplication ────────────────────────────────────────────────────
|
||||
|
||||
# Extract the package name from a .pkg.tar.zst filename.
|
||||
# Arch package filenames follow: {pkgname}-{pkgver}-{pkgrel}-{arch}.pkg.tar.zst
|
||||
# pkgver is guaranteed to have no dashes, so stripping the last three
|
||||
# dash-separated segments leaves pkgname.
|
||||
pkg_name_from_file() {
|
||||
local base
|
||||
base=$(basename "$1" .pkg.tar.zst)
|
||||
base="${base%-*}" # strip arch
|
||||
base="${base%-*}" # strip pkgrel
|
||||
base="${base%-*}" # strip pkgver (no dashes in pkgver by Arch policy)
|
||||
echo "$base"
|
||||
}
|
||||
|
||||
# Deduplicate a list of .pkg.tar.zst paths by package name.
|
||||
# When the same package name appears more than once, keep the highest version
|
||||
# (determined by sort -V on the filenames) and warn about the dropped ones.
|
||||
dedup_inject_files() {
|
||||
[[ $# -eq 0 ]] && return 0
|
||||
local -A best=()
|
||||
local f name winner
|
||||
for f in "$@"; do
|
||||
name=$(pkg_name_from_file "$f")
|
||||
if [[ -v "best[$name]" ]]; then
|
||||
winner=$(printf '%s\n%s\n' "${best[$name]}" "$f" | sort -V | tail -1)
|
||||
if [[ "$winner" == "$f" ]]; then
|
||||
warn "Dropping duplicate inject (older): $(basename "${best[$name]}")"
|
||||
best[$name]="$f"
|
||||
else
|
||||
warn "Dropping duplicate inject (older): $(basename "$f")"
|
||||
fi
|
||||
else
|
||||
best[$name]="$f"
|
||||
fi
|
||||
done
|
||||
printf '%s\n' "${best[@]}"
|
||||
}
|
||||
|
||||
# ─── Dependency resolution ───────────────────────────────────────────────────
|
||||
|
||||
# Return the names of local AUR packages that PKG depends on.
|
||||
local_deps_of() {
|
||||
local pkg="$1"
|
||||
local pkgbuild="$AUR_DIR/$pkg/PKGBUILD"
|
||||
[[ -f "$pkgbuild" ]] || return 0
|
||||
|
||||
local dep_line bare
|
||||
dep_line=$(grep '^depends=' "$pkgbuild" 2>/dev/null | head -1 || true)
|
||||
[[ -z "$dep_line" ]] && return 0
|
||||
|
||||
# Strip depends=, parens, and quotes; split on whitespace
|
||||
echo "$dep_line" \
|
||||
| sed "s/^depends=//; s/[()\"']/ /g" \
|
||||
| tr ' ' '\n' \
|
||||
| while IFS= read -r dep; do
|
||||
[[ -z "$dep" ]] && continue
|
||||
bare="${dep%%[><=]*}" # strip version constraints
|
||||
[[ -d "$AUR_DIR/$bare" ]] && echo "$bare"
|
||||
done
|
||||
}
|
||||
|
||||
# Topological sort (DFS) — deps before dependents.
|
||||
declare -A TOPO_VISITED=()
|
||||
declare -a TOPO_ORDER=()
|
||||
|
||||
topo_visit() {
|
||||
local pkg="$1"
|
||||
[[ -v "TOPO_VISITED[$pkg]" ]] && return 0
|
||||
TOPO_VISITED[$pkg]=1
|
||||
local dep
|
||||
while IFS= read -r dep; do
|
||||
topo_visit "$dep"
|
||||
done < <(local_deps_of "$pkg")
|
||||
TOPO_ORDER+=("$pkg")
|
||||
}
|
||||
|
||||
resolve_order() {
|
||||
TOPO_VISITED=()
|
||||
TOPO_ORDER=()
|
||||
local pkg
|
||||
for pkg in "$@"; do
|
||||
topo_visit "$pkg"
|
||||
done
|
||||
}
|
||||
|
||||
# ─── Tarball creation ────────────────────────────────────────────────────────
|
||||
|
||||
make_tarball() {
|
||||
TMP_TARBALL=$(mktemp /tmp/aur-local-XXXXXX.tar.gz)
|
||||
info "Packaging ${REPO_NAME} working tree (incl. uncommitted changes)..."
|
||||
tar czf "$TMP_TARBALL" \
|
||||
--exclude='.git' \
|
||||
--exclude='target' \
|
||||
--transform "s|^\.|${REPO_NAME}|" \
|
||||
-C "$REPO_ROOT" .
|
||||
ok "Tarball ready: $(du -b "$TMP_TARBALL" | cut -f1 | numfmt --to=iec 2>/dev/null || wc -c < "$TMP_TARBALL") bytes"
|
||||
}
|
||||
|
||||
# ─── PKGBUILD patching ───────────────────────────────────────────────────────
|
||||
|
||||
# Patch a package's PKGBUILD to use the local tarball.
|
||||
# Backs up the original; cleanup() restores it on exit.
|
||||
patch_pkgbuild() {
|
||||
local pkg="$1"
|
||||
local pkgbuild="$AUR_DIR/$pkg/PKGBUILD"
|
||||
local pkgdir="$AUR_DIR/$pkg"
|
||||
|
||||
# Skip packages with no remote source (meta/group packages)
|
||||
if ! grep -q '^source=' "$pkgbuild" || grep -qE '^source=\(\s*\)' "$pkgbuild"; then
|
||||
ok "No source URL to patch — skipping tarball injection for $pkg"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local pkgname pkgver filename hash
|
||||
pkgname=$(grep '^pkgname=' "$pkgbuild" | cut -d= -f2- | tr -d "\"'")
|
||||
pkgver=$(grep '^pkgver=' "$pkgbuild" | cut -d= -f2- | tr -d "\"'")
|
||||
filename="${pkgname}-${pkgver}.tar.gz"
|
||||
hash=$(b2sum "$TMP_TARBALL" | cut -d' ' -f1)
|
||||
|
||||
# Backup original PKGBUILD
|
||||
cp "$pkgbuild" "${pkgbuild}.bak"
|
||||
PKGBUILD_BACKUPS+=("${pkgbuild}.bak")
|
||||
|
||||
# Place local tarball where makepkg looks for it
|
||||
cp "$TMP_TARBALL" "$pkgdir/$filename"
|
||||
PLACED_FILES+=("$pkgdir/$filename")
|
||||
|
||||
# Patch source and checksum lines in-place
|
||||
sed -i "s|^source=.*|source=(\"${filename}\")|" "$pkgbuild"
|
||||
sed -i "s|^b2sums=.*|b2sums=('${hash}')|" "$pkgbuild"
|
||||
|
||||
ok "Patched PKGBUILD: source=${filename}, b2sum=${hash:0:12}…"
|
||||
}
|
||||
|
||||
# ─── Build ───────────────────────────────────────────────────────────────────
|
||||
|
||||
# built_artifacts[pkg] = path to the .pkg.tar.zst produced in this run.
|
||||
# Used to inject pkg artifacts into dependent builds.
|
||||
declare -A BUILT_ARTIFACTS=()
|
||||
|
||||
find_artifact() {
|
||||
local pkg="$1"
|
||||
local pkgver
|
||||
# pkgver is the same in patched and original PKGBUILD
|
||||
pkgver=$(grep '^pkgver=' "$AUR_DIR/$pkg/PKGBUILD" | cut -d= -f2- | tr -d "\"'" \
|
||||
|| grep '^pkgver=' "$AUR_DIR/$pkg/PKGBUILD.bak" | cut -d= -f2- | tr -d "\"'")
|
||||
ls "$AUR_DIR/$pkg/${pkg}-${pkgver}-"*".pkg.tar.zst" 2>/dev/null \
|
||||
| grep -v -- '-debug-' | sort -V | tail -1 || true
|
||||
}
|
||||
|
||||
build_one() {
|
||||
local pkg="$1"
|
||||
local pkgdir="$AUR_DIR/$pkg"
|
||||
|
||||
info "[$pkg] Patching PKGBUILD..."
|
||||
patch_pkgbuild "$pkg"
|
||||
|
||||
# Collect inject args: extra (external) + artifacts of local deps built earlier
|
||||
local inject=()
|
||||
for f in "${EXTRA_INJECT[@]+"${EXTRA_INJECT[@]}"}"; do
|
||||
inject+=("-I" "$f")
|
||||
done
|
||||
while IFS= read -r dep; do
|
||||
if [[ -v "BUILT_ARTIFACTS[$dep]" ]]; then
|
||||
inject+=("-I" "${BUILT_ARTIFACTS[$dep]}")
|
||||
else
|
||||
warn "$pkg depends on $dep (local AUR) which was not built in this run"
|
||||
warn " → Build $dep first or pass: -I path/to/${dep}-*.pkg.tar.zst"
|
||||
fi
|
||||
done < <(local_deps_of "$pkg")
|
||||
|
||||
# Build args: -c only on the first package, then cleared
|
||||
local build_args=()
|
||||
if [[ $RESET_CHROOT -eq 1 ]]; then
|
||||
build_args+=("-c")
|
||||
RESET_CHROOT=0
|
||||
fi
|
||||
|
||||
info "[$pkg] Running extra-x86_64-build..."
|
||||
(
|
||||
cd "$pkgdir"
|
||||
if [[ ${#inject[@]} -gt 0 ]]; then
|
||||
extra-x86_64-build "${build_args[@]+"${build_args[@]}"}" -- "${inject[@]}"
|
||||
else
|
||||
extra-x86_64-build "${build_args[@]+"${build_args[@]}"}"
|
||||
fi
|
||||
)
|
||||
|
||||
# Record artifact for potential injection into dependents
|
||||
local artifact
|
||||
artifact=$(find_artifact "$pkg")
|
||||
if [[ -n "$artifact" ]]; then
|
||||
BUILT_ARTIFACTS[$pkg]="$artifact"
|
||||
ok "[$pkg] artifact: $(basename "$artifact")"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Deduplicate external inject files by package name (keep highest version)
|
||||
if [[ ${#EXTRA_INJECT[@]} -gt 1 ]]; then
|
||||
mapfile -t EXTRA_INJECT < <(dedup_inject_files "${EXTRA_INJECT[@]}")
|
||||
fi
|
||||
|
||||
# Validate all requested packages exist
|
||||
for pkg in "${INPUT_PKGS[@]}"; do
|
||||
[[ -d "$AUR_DIR/$pkg" && -f "$AUR_DIR/$pkg/PKGBUILD" ]] \
|
||||
|| die "package not found: aur/$pkg/PKGBUILD"
|
||||
done
|
||||
|
||||
# Sort into build order (deps before dependents)
|
||||
resolve_order "${INPUT_PKGS[@]}"
|
||||
|
||||
# Create one tarball, reused for all packages in this run
|
||||
make_tarball
|
||||
|
||||
declare -a FAILED=()
|
||||
|
||||
for pkg in "${TOPO_ORDER[@]}"; do
|
||||
echo ""
|
||||
if build_one "$pkg"; then
|
||||
:
|
||||
else
|
||||
fail "[$pkg]"
|
||||
FAILED+=("$pkg")
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
if [[ ${#FAILED[@]} -gt 0 ]]; then
|
||||
fail "packages failed: ${FAILED[*]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
info "All packages built successfully!"
|
||||
@@ -5,7 +5,8 @@ After=graphical-session.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/owlry-core
|
||||
ExecStart=/usr/bin/owlryd
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
Environment=RUST_LOG=warn
|
||||
Reference in New Issue
Block a user