refactor: remove plugin crates from core repo
Plugins have been moved to the owlry-plugins repo. This removes: - All 13 owlry-plugin-* crate directories - Plugin documentation (PLUGINS.md, PLUGIN_DEVELOPMENT.md) - Plugin-specific justfile targets (build, bump, AUR) Retained in core: owlry (UI), owlry-core (daemon), owlry-plugin-api (ABI interface), owlry-lua, owlry-rune (runtimes).
This commit is contained in:
207
Cargo.lock
generated
207
Cargo.lock
generated
@@ -835,18 +835,6 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.3.0"
|
||||
@@ -891,12 +879,6 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@@ -1521,7 +1503,7 @@ version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash 0.1.5",
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1529,18 +1511,6 @@ name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
@@ -1976,17 +1946,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.37.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
@@ -2527,127 +2486,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "owlry-plugin-bookmarks"
|
||||
version = "0.4.10"
|
||||
dependencies = [
|
||||
"abi_stable",
|
||||
"dirs",
|
||||
"owlry-plugin-api",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "owlry-plugin-calculator"
|
||||
version = "0.4.10"
|
||||
dependencies = [
|
||||
"abi_stable",
|
||||
"meval",
|
||||
"owlry-plugin-api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "owlry-plugin-clipboard"
|
||||
version = "0.4.10"
|
||||
dependencies = [
|
||||
"abi_stable",
|
||||
"owlry-plugin-api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "owlry-plugin-emoji"
|
||||
version = "0.4.10"
|
||||
dependencies = [
|
||||
"abi_stable",
|
||||
"owlry-plugin-api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "owlry-plugin-filesearch"
|
||||
version = "0.4.10"
|
||||
dependencies = [
|
||||
"abi_stable",
|
||||
"dirs",
|
||||
"owlry-plugin-api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "owlry-plugin-media"
|
||||
version = "0.4.10"
|
||||
dependencies = [
|
||||
"abi_stable",
|
||||
"owlry-plugin-api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "owlry-plugin-pomodoro"
|
||||
version = "0.4.10"
|
||||
dependencies = [
|
||||
"abi_stable",
|
||||
"dirs",
|
||||
"owlry-plugin-api",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml 0.8.23",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "owlry-plugin-scripts"
|
||||
version = "0.4.10"
|
||||
dependencies = [
|
||||
"abi_stable",
|
||||
"dirs",
|
||||
"owlry-plugin-api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "owlry-plugin-ssh"
|
||||
version = "0.4.10"
|
||||
dependencies = [
|
||||
"abi_stable",
|
||||
"dirs",
|
||||
"owlry-plugin-api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "owlry-plugin-system"
|
||||
version = "0.4.10"
|
||||
dependencies = [
|
||||
"abi_stable",
|
||||
"owlry-plugin-api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "owlry-plugin-systemd"
|
||||
version = "0.4.10"
|
||||
dependencies = [
|
||||
"abi_stable",
|
||||
"owlry-plugin-api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "owlry-plugin-weather"
|
||||
version = "0.4.10"
|
||||
dependencies = [
|
||||
"abi_stable",
|
||||
"dirs",
|
||||
"owlry-plugin-api",
|
||||
"reqwest 0.13.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml 0.8.23",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "owlry-plugin-websearch"
|
||||
version = "0.4.10"
|
||||
dependencies = [
|
||||
"abi_stable",
|
||||
"owlry-plugin-api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "owlry-rune"
|
||||
version = "0.4.10"
|
||||
@@ -3129,16 +2967,6 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsqlite-vfs"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rune"
|
||||
version = "0.14.1"
|
||||
@@ -3247,21 +3075,6 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.39.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
"sqlite-wasm-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.1"
|
||||
@@ -3590,18 +3403,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlite-wasm-rs"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"js-sys",
|
||||
"rsqlite-vfs",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
@@ -4218,12 +4019,6 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.1"
|
||||
|
||||
15
Cargo.toml
15
Cargo.toml
@@ -2,23 +2,10 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/owlry",
|
||||
"crates/owlry-core",
|
||||
"crates/owlry-plugin-api",
|
||||
"crates/owlry-plugin-calculator",
|
||||
"crates/owlry-plugin-system",
|
||||
"crates/owlry-plugin-ssh",
|
||||
"crates/owlry-plugin-clipboard",
|
||||
"crates/owlry-plugin-emoji",
|
||||
"crates/owlry-plugin-scripts",
|
||||
"crates/owlry-plugin-bookmarks",
|
||||
"crates/owlry-plugin-websearch",
|
||||
"crates/owlry-plugin-filesearch",
|
||||
"crates/owlry-plugin-weather",
|
||||
"crates/owlry-plugin-media",
|
||||
"crates/owlry-plugin-pomodoro",
|
||||
"crates/owlry-plugin-systemd",
|
||||
"crates/owlry-lua",
|
||||
"crates/owlry-rune",
|
||||
"crates/owlry-core",
|
||||
]
|
||||
|
||||
# Shared workspace settings
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
[package]
|
||||
name = "owlry-plugin-bookmarks"
|
||||
version = "0.4.10"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Bookmarks plugin for owlry - browser bookmark search"
|
||||
keywords = ["owlry", "plugin", "bookmarks", "browser"]
|
||||
categories = ["web-programming"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
|
||||
# For finding browser config directories
|
||||
dirs = "5.0"
|
||||
|
||||
# For parsing Chrome bookmarks JSON
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# For reading Firefox bookmarks (places.sqlite)
|
||||
# Use bundled SQLite to avoid system library version conflicts
|
||||
rusqlite = { version = "0.39", features = ["bundled"] }
|
||||
@@ -1,662 +0,0 @@
|
||||
//! Bookmarks Plugin for Owlry
|
||||
//!
|
||||
//! A static provider that reads browser bookmarks from various browsers.
|
||||
//!
|
||||
//! Supported browsers:
|
||||
//! - Firefox (via places.sqlite using rusqlite with bundled SQLite)
|
||||
//! - Chrome
|
||||
//! - Chromium
|
||||
//! - Brave
|
||||
//! - Edge
|
||||
|
||||
use abi_stable::std_types::{ROption, RStr, RString, RVec};
|
||||
use owlry_plugin_api::{
|
||||
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
|
||||
ProviderPosition, API_VERSION,
|
||||
};
|
||||
use rusqlite::{Connection, OpenFlags};
|
||||
use serde::Deserialize;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
|
||||
// Plugin metadata
|
||||
const PLUGIN_ID: &str = "bookmarks";
|
||||
const PLUGIN_NAME: &str = "Bookmarks";
|
||||
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const PLUGIN_DESCRIPTION: &str = "Browser bookmark search";
|
||||
|
||||
// Provider metadata
|
||||
const PROVIDER_ID: &str = "bookmarks";
|
||||
const PROVIDER_NAME: &str = "Bookmarks";
|
||||
const PROVIDER_PREFIX: &str = ":bm";
|
||||
const PROVIDER_ICON: &str = "user-bookmarks-symbolic";
|
||||
const PROVIDER_TYPE_ID: &str = "bookmarks";
|
||||
|
||||
/// Bookmarks provider state - holds cached items
|
||||
struct BookmarksState {
|
||||
/// Cached bookmark items (returned immediately on refresh)
|
||||
items: Vec<PluginItem>,
|
||||
/// Flag to prevent concurrent background loads
|
||||
loading: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl BookmarksState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
items: Vec::new(),
|
||||
loading: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get or create the favicon cache directory
|
||||
fn favicon_cache_dir() -> Option<PathBuf> {
|
||||
dirs::cache_dir().map(|d| d.join("owlry/favicons"))
|
||||
}
|
||||
|
||||
/// Ensure the favicon cache directory exists
|
||||
fn ensure_favicon_cache_dir() -> Option<PathBuf> {
|
||||
Self::favicon_cache_dir().and_then(|dir| {
|
||||
fs::create_dir_all(&dir).ok()?;
|
||||
Some(dir)
|
||||
})
|
||||
}
|
||||
|
||||
/// Hash a URL to create a cache filename
|
||||
fn url_to_cache_filename(url: &str) -> String {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
url.hash(&mut hasher);
|
||||
format!("{:016x}.png", hasher.finish())
|
||||
}
|
||||
|
||||
/// Get the bookmark cache file path
|
||||
fn bookmark_cache_file() -> Option<PathBuf> {
|
||||
dirs::cache_dir().map(|d| d.join("owlry/bookmarks.json"))
|
||||
}
|
||||
|
||||
/// Load cached bookmarks from disk (fast)
|
||||
fn load_cached_bookmarks() -> Vec<PluginItem> {
|
||||
let cache_file = match Self::bookmark_cache_file() {
|
||||
Some(f) => f,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
|
||||
if !cache_file.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let content = match fs::read_to_string(&cache_file) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
// Parse cached bookmarks (simple JSON format)
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CachedBookmark {
|
||||
id: String,
|
||||
name: String,
|
||||
command: String,
|
||||
description: Option<String>,
|
||||
icon: String,
|
||||
}
|
||||
|
||||
let cached: Vec<CachedBookmark> = match serde_json::from_str(&content) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
cached
|
||||
.into_iter()
|
||||
.map(|b| {
|
||||
let mut item = PluginItem::new(b.id, b.name, b.command)
|
||||
.with_icon(&b.icon)
|
||||
.with_keywords(vec!["bookmark".to_string()]);
|
||||
if let Some(desc) = b.description {
|
||||
item = item.with_description(desc);
|
||||
}
|
||||
item
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Save bookmarks to cache file
|
||||
fn save_cached_bookmarks(items: &[PluginItem]) {
|
||||
let cache_file = match Self::bookmark_cache_file() {
|
||||
Some(f) => f,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Ensure cache directory exists
|
||||
if let Some(parent) = cache_file.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct CachedBookmark {
|
||||
id: String,
|
||||
name: String,
|
||||
command: String,
|
||||
description: Option<String>,
|
||||
icon: String,
|
||||
}
|
||||
|
||||
let cached: Vec<CachedBookmark> = items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
let desc: Option<String> = match &item.description {
|
||||
abi_stable::std_types::ROption::RSome(s) => Some(s.to_string()),
|
||||
abi_stable::std_types::ROption::RNone => None,
|
||||
};
|
||||
let icon: String = match &item.icon {
|
||||
abi_stable::std_types::ROption::RSome(s) => s.to_string(),
|
||||
abi_stable::std_types::ROption::RNone => PROVIDER_ICON.to_string(),
|
||||
};
|
||||
CachedBookmark {
|
||||
id: item.id.to_string(),
|
||||
name: item.name.to_string(),
|
||||
command: item.command.to_string(),
|
||||
description: desc,
|
||||
icon,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&cached) {
|
||||
let _ = fs::write(&cache_file, json);
|
||||
}
|
||||
}
|
||||
|
||||
fn chromium_bookmark_paths() -> Vec<PathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
|
||||
if let Some(config_dir) = dirs::config_dir() {
|
||||
// Chrome
|
||||
paths.push(config_dir.join("google-chrome/Default/Bookmarks"));
|
||||
paths.push(config_dir.join("google-chrome-stable/Default/Bookmarks"));
|
||||
|
||||
// Chromium
|
||||
paths.push(config_dir.join("chromium/Default/Bookmarks"));
|
||||
|
||||
// Brave
|
||||
paths.push(config_dir.join("BraveSoftware/Brave-Browser/Default/Bookmarks"));
|
||||
|
||||
// Edge
|
||||
paths.push(config_dir.join("microsoft-edge/Default/Bookmarks"));
|
||||
}
|
||||
|
||||
paths
|
||||
}
|
||||
|
||||
fn firefox_places_paths() -> Vec<PathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let firefox_dir = home.join(".mozilla/firefox");
|
||||
if firefox_dir.exists() {
|
||||
// Find all profile directories
|
||||
if let Ok(entries) = fs::read_dir(&firefox_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
let places = path.join("places.sqlite");
|
||||
if places.exists() {
|
||||
paths.push(places);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
paths
|
||||
}
|
||||
|
||||
/// Find Firefox favicons.sqlite paths (paired with places.sqlite)
|
||||
fn firefox_favicons_path(places_path: &Path) -> Option<PathBuf> {
|
||||
let favicons = places_path.parent()?.join("favicons.sqlite");
|
||||
if favicons.exists() {
|
||||
Some(favicons)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn load_bookmarks(&mut self) {
|
||||
// Fast path: load from cache immediately
|
||||
if self.items.is_empty() {
|
||||
self.items = Self::load_cached_bookmarks();
|
||||
}
|
||||
|
||||
// Don't start another background load if one is already running
|
||||
if self.loading.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Spawn background thread to refresh bookmarks
|
||||
let loading = self.loading.clone();
|
||||
thread::spawn(move || {
|
||||
let mut items = Vec::new();
|
||||
|
||||
// Load Chrome/Chromium bookmarks (fast - just JSON parsing)
|
||||
for path in Self::chromium_bookmark_paths() {
|
||||
if path.exists() {
|
||||
Self::read_chrome_bookmarks_static(&path, &mut items);
|
||||
}
|
||||
}
|
||||
|
||||
// Load Firefox bookmarks with favicons (synchronous with rusqlite)
|
||||
for path in Self::firefox_places_paths() {
|
||||
Self::read_firefox_bookmarks(&path, &mut items);
|
||||
}
|
||||
|
||||
// Save to cache for next startup
|
||||
Self::save_cached_bookmarks(&items);
|
||||
|
||||
loading.store(false, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
|
||||
/// Read Chrome bookmarks (static helper for background thread)
|
||||
fn read_chrome_bookmarks_static(path: &PathBuf, items: &mut Vec<PluginItem>) {
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let bookmarks: ChromeBookmarks = match serde_json::from_str(&content) {
|
||||
Ok(b) => b,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
if let Some(roots) = bookmarks.roots {
|
||||
if let Some(bar) = roots.bookmark_bar {
|
||||
Self::process_chrome_folder_static(&bar, items);
|
||||
}
|
||||
if let Some(other) = roots.other {
|
||||
Self::process_chrome_folder_static(&other, items);
|
||||
}
|
||||
if let Some(synced) = roots.synced {
|
||||
Self::process_chrome_folder_static(&synced, items);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn process_chrome_folder_static(folder: &ChromeBookmarkNode, items: &mut Vec<PluginItem>) {
|
||||
if let Some(ref children) = folder.children {
|
||||
for child in children {
|
||||
match child.node_type.as_deref() {
|
||||
Some("url") => {
|
||||
if let Some(ref url) = child.url {
|
||||
let name = child.name.clone().unwrap_or_else(|| url.clone());
|
||||
items.push(
|
||||
PluginItem::new(
|
||||
format!("bookmark:{}", url),
|
||||
name,
|
||||
format!("xdg-open '{}'", url.replace('\'', "'\\''")),
|
||||
)
|
||||
.with_description(url.clone())
|
||||
.with_icon(PROVIDER_ICON)
|
||||
.with_keywords(vec!["bookmark".to_string(), "chrome".to_string()]),
|
||||
);
|
||||
}
|
||||
}
|
||||
Some("folder") => {
|
||||
Self::process_chrome_folder_static(child, items);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read Firefox bookmarks using rusqlite (synchronous, bundled SQLite)
|
||||
fn read_firefox_bookmarks(places_path: &PathBuf, items: &mut Vec<PluginItem>) {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let temp_db = temp_dir.join("owlry_places_temp.sqlite");
|
||||
|
||||
// Copy database to temp location to avoid locking issues
|
||||
if fs::copy(places_path, &temp_db).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Also copy WAL file if it exists
|
||||
let wal_path = places_path.with_extension("sqlite-wal");
|
||||
if wal_path.exists() {
|
||||
let temp_wal = temp_db.with_extension("sqlite-wal");
|
||||
let _ = fs::copy(&wal_path, &temp_wal);
|
||||
}
|
||||
|
||||
// Copy favicons database if available
|
||||
let favicons_path = Self::firefox_favicons_path(places_path);
|
||||
let temp_favicons = temp_dir.join("owlry_favicons_temp.sqlite");
|
||||
if let Some(ref fp) = favicons_path {
|
||||
let _ = fs::copy(fp, &temp_favicons);
|
||||
let fav_wal = fp.with_extension("sqlite-wal");
|
||||
if fav_wal.exists() {
|
||||
let _ = fs::copy(&fav_wal, temp_favicons.with_extension("sqlite-wal"));
|
||||
}
|
||||
}
|
||||
|
||||
let cache_dir = Self::ensure_favicon_cache_dir();
|
||||
|
||||
// Read bookmarks from places.sqlite
|
||||
let bookmarks = Self::fetch_firefox_bookmarks(&temp_db, &temp_favicons, cache_dir.as_ref());
|
||||
|
||||
// Clean up temp files
|
||||
let _ = fs::remove_file(&temp_db);
|
||||
let _ = fs::remove_file(temp_db.with_extension("sqlite-wal"));
|
||||
let _ = fs::remove_file(&temp_favicons);
|
||||
let _ = fs::remove_file(temp_favicons.with_extension("sqlite-wal"));
|
||||
|
||||
for (title, url, favicon_path) in bookmarks {
|
||||
let icon = favicon_path.unwrap_or_else(|| PROVIDER_ICON.to_string());
|
||||
items.push(
|
||||
PluginItem::new(
|
||||
format!("bookmark:firefox:{}", url),
|
||||
title,
|
||||
format!("xdg-open '{}'", url.replace('\'', "'\\''")),
|
||||
)
|
||||
.with_description(url)
|
||||
.with_icon(&icon)
|
||||
.with_keywords(vec!["bookmark".to_string(), "firefox".to_string()]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch Firefox bookmarks with optional favicons
|
||||
fn fetch_firefox_bookmarks(
|
||||
places_path: &Path,
|
||||
favicons_path: &Path,
|
||||
cache_dir: Option<&PathBuf>,
|
||||
) -> Vec<(String, String, Option<String>)> {
|
||||
// Open places.sqlite in read-only mode
|
||||
let conn = match Connection::open_with_flags(
|
||||
places_path,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
// Query bookmarks joining moz_bookmarks with moz_places
|
||||
// type=1 means URL bookmarks (not folders, separators, etc.)
|
||||
let query = r#"
|
||||
SELECT b.title, p.url
|
||||
FROM moz_bookmarks b
|
||||
JOIN moz_places p ON b.fk = p.id
|
||||
WHERE b.type = 1
|
||||
AND p.url NOT LIKE 'place:%'
|
||||
AND p.url NOT LIKE 'about:%'
|
||||
AND b.title IS NOT NULL
|
||||
AND b.title != ''
|
||||
ORDER BY b.dateAdded DESC
|
||||
LIMIT 500
|
||||
"#;
|
||||
|
||||
let mut stmt = match conn.prepare(query) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
let bookmarks: Vec<(String, String)> = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.ok()
|
||||
.map(|rows| rows.filter_map(|r| r.ok()).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
// If no favicons or cache dir, return without favicons
|
||||
let cache_dir = match cache_dir {
|
||||
Some(c) => c,
|
||||
None => return bookmarks.into_iter().map(|(t, u)| (t, u, None)).collect(),
|
||||
};
|
||||
|
||||
// Try to open favicons database
|
||||
let fav_conn = match Connection::open_with_flags(
|
||||
favicons_path,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return bookmarks.into_iter().map(|(t, u)| (t, u, None)).collect(),
|
||||
};
|
||||
|
||||
// Fetch favicons for each URL
|
||||
let mut results = Vec::new();
|
||||
for (title, url) in bookmarks {
|
||||
let favicon_path = Self::get_favicon_for_url(&fav_conn, &url, cache_dir);
|
||||
results.push((title, url, favicon_path));
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
/// Get favicon for a URL, caching to file if needed
|
||||
fn get_favicon_for_url(
|
||||
conn: &Connection,
|
||||
page_url: &str,
|
||||
cache_dir: &Path,
|
||||
) -> Option<String> {
|
||||
// Check if already cached
|
||||
let cache_filename = Self::url_to_cache_filename(page_url);
|
||||
let cache_path = cache_dir.join(&cache_filename);
|
||||
if cache_path.exists() {
|
||||
return Some(cache_path.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
// Query favicon data from database
|
||||
// Join moz_pages_w_icons -> moz_icons_to_pages -> moz_icons
|
||||
// Prefer smaller icons (32px) for efficiency
|
||||
let query = r#"
|
||||
SELECT i.data
|
||||
FROM moz_pages_w_icons p
|
||||
JOIN moz_icons_to_pages ip ON p.id = ip.page_id
|
||||
JOIN moz_icons i ON ip.icon_id = i.id
|
||||
WHERE p.page_url = ?
|
||||
AND i.data IS NOT NULL
|
||||
ORDER BY ABS(i.width - 32) ASC
|
||||
LIMIT 1
|
||||
"#;
|
||||
|
||||
let data: Option<Vec<u8>> = conn
|
||||
.query_row(query, [page_url], |row| row.get(0))
|
||||
.ok();
|
||||
|
||||
let data = data?;
|
||||
if data.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Write favicon data to cache file
|
||||
let mut file = fs::File::create(&cache_path).ok()?;
|
||||
file.write_all(&data).ok()?;
|
||||
|
||||
Some(cache_path.to_string_lossy().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// Chrome bookmark JSON structures
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ChromeBookmarks {
|
||||
roots: Option<ChromeBookmarkRoots>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ChromeBookmarkRoots {
|
||||
bookmark_bar: Option<ChromeBookmarkNode>,
|
||||
other: Option<ChromeBookmarkNode>,
|
||||
synced: Option<ChromeBookmarkNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ChromeBookmarkNode {
|
||||
name: Option<String>,
|
||||
url: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
node_type: Option<String>,
|
||||
children: Option<Vec<ChromeBookmarkNode>>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Interface Implementation
|
||||
// ============================================================================
|
||||
|
||||
extern "C" fn plugin_info() -> PluginInfo {
|
||||
PluginInfo {
|
||||
id: RString::from(PLUGIN_ID),
|
||||
name: RString::from(PLUGIN_NAME),
|
||||
version: RString::from(PLUGIN_VERSION),
|
||||
description: RString::from(PLUGIN_DESCRIPTION),
|
||||
api_version: API_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
|
||||
vec![ProviderInfo {
|
||||
id: RString::from(PROVIDER_ID),
|
||||
name: RString::from(PROVIDER_NAME),
|
||||
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
|
||||
icon: RString::from(PROVIDER_ICON),
|
||||
provider_type: ProviderKind::Static,
|
||||
type_id: RString::from(PROVIDER_TYPE_ID),
|
||||
position: ProviderPosition::Normal,
|
||||
priority: 0, // Static: use frecency ordering
|
||||
}]
|
||||
.into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
|
||||
let state = Box::new(BookmarksState::new());
|
||||
ProviderHandle::from_box(state)
|
||||
}
|
||||
|
||||
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
|
||||
if handle.ptr.is_null() {
|
||||
return RVec::new();
|
||||
}
|
||||
|
||||
// SAFETY: We created this handle from Box<BookmarksState>
|
||||
let state = unsafe { &mut *(handle.ptr as *mut BookmarksState) };
|
||||
|
||||
// Load bookmarks
|
||||
state.load_bookmarks();
|
||||
|
||||
// Return items
|
||||
state.items.to_vec().into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec<PluginItem> {
|
||||
// Static provider - query is handled by the core using cached items
|
||||
RVec::new()
|
||||
}
|
||||
|
||||
extern "C" fn provider_drop(handle: ProviderHandle) {
|
||||
if !handle.ptr.is_null() {
|
||||
// SAFETY: We created this handle from Box<BookmarksState>
|
||||
unsafe {
|
||||
handle.drop_as::<BookmarksState>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the plugin vtable
|
||||
owlry_plugin! {
|
||||
info: plugin_info,
|
||||
providers: plugin_providers,
|
||||
init: provider_init,
|
||||
refresh: provider_refresh,
|
||||
query: provider_query,
|
||||
drop: provider_drop,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_bookmarks_state_new() {
|
||||
let state = BookmarksState::new();
|
||||
assert!(state.items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chromium_paths() {
|
||||
let paths = BookmarksState::chromium_bookmark_paths();
|
||||
// Should have at least some paths configured
|
||||
assert!(!paths.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_firefox_paths() {
|
||||
// This will find paths if Firefox is installed
|
||||
let paths = BookmarksState::firefox_places_paths();
|
||||
// Path detection should work (may be empty if Firefox not installed)
|
||||
let _ = paths.len(); // Just ensure it doesn't panic
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_chrome_bookmarks() {
|
||||
let json = r#"{
|
||||
"roots": {
|
||||
"bookmark_bar": {
|
||||
"type": "folder",
|
||||
"children": [
|
||||
{
|
||||
"type": "url",
|
||||
"name": "Example",
|
||||
"url": "https://example.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let bookmarks: ChromeBookmarks = serde_json::from_str(json).unwrap();
|
||||
assert!(bookmarks.roots.is_some());
|
||||
|
||||
let roots = bookmarks.roots.unwrap();
|
||||
assert!(roots.bookmark_bar.is_some());
|
||||
|
||||
let bar = roots.bookmark_bar.unwrap();
|
||||
assert!(bar.children.is_some());
|
||||
assert_eq!(bar.children.unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_folder() {
|
||||
let mut items = Vec::new();
|
||||
|
||||
let folder = ChromeBookmarkNode {
|
||||
name: Some("Test Folder".to_string()),
|
||||
url: None,
|
||||
node_type: Some("folder".to_string()),
|
||||
children: Some(vec![
|
||||
ChromeBookmarkNode {
|
||||
name: Some("Test Bookmark".to_string()),
|
||||
url: Some("https://test.com".to_string()),
|
||||
node_type: Some("url".to_string()),
|
||||
children: None,
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
BookmarksState::process_chrome_folder_static(&folder, &mut items);
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].name.as_str(), "Test Bookmark");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_url_escaping() {
|
||||
let url = "https://example.com/path?query='test'";
|
||||
let command = format!("xdg-open '{}'", url.replace('\'', "'\\''"));
|
||||
assert!(command.contains("'\\''"));
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
[package]
|
||||
name = "owlry-plugin-calculator"
|
||||
version = "0.4.10"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Calculator plugin for owlry - evaluates mathematical expressions"
|
||||
keywords = ["owlry", "plugin", "calculator"]
|
||||
categories = ["mathematics"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# Math expression evaluation
|
||||
meval = "0.2"
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
@@ -1,231 +0,0 @@
|
||||
//! Calculator Plugin for Owlry
|
||||
//!
|
||||
//! A dynamic provider that evaluates mathematical expressions.
|
||||
//! Supports queries prefixed with `=` or `calc `.
|
||||
//!
|
||||
//! Examples:
|
||||
//! - `= 5 + 3` → 8
|
||||
//! - `calc sqrt(16)` → 4
|
||||
//! - `= pi * 2` → 6.283185...
|
||||
|
||||
use abi_stable::std_types::{ROption, RStr, RString, RVec};
|
||||
use owlry_plugin_api::{
|
||||
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
|
||||
ProviderPosition, API_VERSION,
|
||||
};
|
||||
|
||||
// Plugin metadata
|
||||
const PLUGIN_ID: &str = "calculator";
|
||||
const PLUGIN_NAME: &str = "Calculator";
|
||||
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const PLUGIN_DESCRIPTION: &str = "Evaluate mathematical expressions";
|
||||
|
||||
// Provider metadata
|
||||
const PROVIDER_ID: &str = "calculator";
|
||||
const PROVIDER_NAME: &str = "Calculator";
|
||||
const PROVIDER_PREFIX: &str = "=";
|
||||
const PROVIDER_ICON: &str = "accessories-calculator";
|
||||
const PROVIDER_TYPE_ID: &str = "calc";
|
||||
|
||||
/// Calculator provider state (empty for now, but could cache results)
|
||||
struct CalculatorState;
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Interface Implementation
|
||||
// ============================================================================
|
||||
|
||||
extern "C" fn plugin_info() -> PluginInfo {
|
||||
PluginInfo {
|
||||
id: RString::from(PLUGIN_ID),
|
||||
name: RString::from(PLUGIN_NAME),
|
||||
version: RString::from(PLUGIN_VERSION),
|
||||
description: RString::from(PLUGIN_DESCRIPTION),
|
||||
api_version: API_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
|
||||
vec![ProviderInfo {
|
||||
id: RString::from(PROVIDER_ID),
|
||||
name: RString::from(PROVIDER_NAME),
|
||||
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
|
||||
icon: RString::from(PROVIDER_ICON),
|
||||
provider_type: ProviderKind::Dynamic,
|
||||
type_id: RString::from(PROVIDER_TYPE_ID),
|
||||
position: ProviderPosition::Normal,
|
||||
priority: 10000, // Dynamic: calculator results first
|
||||
}]
|
||||
.into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
|
||||
// Create state and return handle
|
||||
let state = Box::new(CalculatorState);
|
||||
ProviderHandle::from_box(state)
|
||||
}
|
||||
|
||||
extern "C" fn provider_refresh(_handle: ProviderHandle) -> RVec<PluginItem> {
|
||||
// Dynamic provider - refresh does nothing
|
||||
RVec::new()
|
||||
}
|
||||
|
||||
extern "C" fn provider_query(_handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem> {
|
||||
let query_str = query.as_str();
|
||||
|
||||
// Extract expression from query
|
||||
let expr = match extract_expression(query_str) {
|
||||
Some(e) if !e.is_empty() => e,
|
||||
_ => return RVec::new(),
|
||||
};
|
||||
|
||||
// Evaluate the expression
|
||||
match evaluate_expression(expr) {
|
||||
Some(item) => vec![item].into(),
|
||||
None => RVec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn provider_drop(handle: ProviderHandle) {
|
||||
if !handle.ptr.is_null() {
|
||||
// SAFETY: We created this handle from Box<CalculatorState>
|
||||
unsafe {
|
||||
handle.drop_as::<CalculatorState>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the plugin vtable
|
||||
owlry_plugin! {
|
||||
info: plugin_info,
|
||||
providers: plugin_providers,
|
||||
init: provider_init,
|
||||
refresh: provider_refresh,
|
||||
query: provider_query,
|
||||
drop: provider_drop,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Calculator Logic
|
||||
// ============================================================================
|
||||
|
||||
/// Extract expression from query (handles `= expr` and `calc expr` formats)
|
||||
fn extract_expression(query: &str) -> Option<&str> {
|
||||
let trimmed = query.trim();
|
||||
|
||||
// Support both "= expr" and "=expr" (with or without space)
|
||||
if let Some(expr) = trimmed.strip_prefix("= ") {
|
||||
Some(expr.trim())
|
||||
} else if let Some(expr) = trimmed.strip_prefix('=') {
|
||||
Some(expr.trim())
|
||||
} else if let Some(expr) = trimmed.strip_prefix("calc ") {
|
||||
Some(expr.trim())
|
||||
} else {
|
||||
// For filter mode - accept raw expressions
|
||||
Some(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate a mathematical expression and return a PluginItem
|
||||
fn evaluate_expression(expr: &str) -> Option<PluginItem> {
|
||||
match meval::eval_str(expr) {
|
||||
Ok(result) => {
|
||||
// Format result nicely
|
||||
let result_str = format_result(result);
|
||||
|
||||
Some(
|
||||
PluginItem::new(
|
||||
format!("calc:{}", expr),
|
||||
result_str.clone(),
|
||||
format!("sh -c 'echo -n \"{}\" | wl-copy'", result_str),
|
||||
)
|
||||
.with_description(format!("= {}", expr))
|
||||
.with_icon(PROVIDER_ICON)
|
||||
.with_keywords(vec!["math".to_string(), "calculator".to_string()]),
|
||||
)
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a numeric result nicely
|
||||
fn format_result(result: f64) -> String {
|
||||
if result.fract() == 0.0 && result.abs() < 1e15 {
|
||||
// Integer result
|
||||
format!("{}", result as i64)
|
||||
} else {
|
||||
// Float result with reasonable precision, trimming trailing zeros
|
||||
let formatted = format!("{:.10}", result);
|
||||
formatted
|
||||
.trim_end_matches('0')
|
||||
.trim_end_matches('.')
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_expression() {
|
||||
assert_eq!(extract_expression("= 5+3"), Some("5+3"));
|
||||
assert_eq!(extract_expression("=5+3"), Some("5+3"));
|
||||
assert_eq!(extract_expression("calc 5+3"), Some("5+3"));
|
||||
assert_eq!(extract_expression(" = 5 + 3 "), Some("5 + 3"));
|
||||
assert_eq!(extract_expression("5+3"), Some("5+3")); // Raw expression
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_result() {
|
||||
assert_eq!(format_result(8.0), "8");
|
||||
assert_eq!(format_result(2.5), "2.5");
|
||||
assert_eq!(format_result(3.14159265358979), "3.1415926536");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_basic() {
|
||||
let item = evaluate_expression("5+3").unwrap();
|
||||
assert_eq!(item.name.as_str(), "8");
|
||||
|
||||
let item = evaluate_expression("10 * 2").unwrap();
|
||||
assert_eq!(item.name.as_str(), "20");
|
||||
|
||||
let item = evaluate_expression("15 / 3").unwrap();
|
||||
assert_eq!(item.name.as_str(), "5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_float() {
|
||||
let item = evaluate_expression("5/2").unwrap();
|
||||
assert_eq!(item.name.as_str(), "2.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_functions() {
|
||||
let item = evaluate_expression("sqrt(16)").unwrap();
|
||||
assert_eq!(item.name.as_str(), "4");
|
||||
|
||||
let item = evaluate_expression("abs(-5)").unwrap();
|
||||
assert_eq!(item.name.as_str(), "5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_constants() {
|
||||
let item = evaluate_expression("pi").unwrap();
|
||||
assert!(item.name.as_str().starts_with("3.14159"));
|
||||
|
||||
let item = evaluate_expression("e").unwrap();
|
||||
assert!(item.name.as_str().starts_with("2.718"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_invalid() {
|
||||
assert!(evaluate_expression("").is_none());
|
||||
assert!(evaluate_expression("invalid").is_none());
|
||||
assert!(evaluate_expression("5 +").is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
[package]
|
||||
name = "owlry-plugin-clipboard"
|
||||
version = "0.4.10"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Clipboard plugin for owlry - clipboard history via cliphist"
|
||||
keywords = ["owlry", "plugin", "clipboard"]
|
||||
categories = ["os"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
@@ -1,259 +0,0 @@
|
||||
//! Clipboard Plugin for Owlry
|
||||
//!
|
||||
//! A static provider that integrates with cliphist to show clipboard history.
|
||||
//! Requires cliphist and wl-clipboard to be installed.
|
||||
//!
|
||||
//! Dependencies:
|
||||
//! - cliphist: clipboard history manager
|
||||
//! - wl-clipboard: Wayland clipboard utilities (wl-copy)
|
||||
|
||||
use abi_stable::std_types::{ROption, RStr, RString, RVec};
|
||||
use owlry_plugin_api::{
|
||||
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
|
||||
ProviderPosition, API_VERSION,
|
||||
};
|
||||
use std::process::Command;
|
||||
|
||||
// Plugin metadata
|
||||
const PLUGIN_ID: &str = "clipboard";
|
||||
const PLUGIN_NAME: &str = "Clipboard";
|
||||
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const PLUGIN_DESCRIPTION: &str = "Clipboard history via cliphist";
|
||||
|
||||
// Provider metadata
|
||||
const PROVIDER_ID: &str = "clipboard";
|
||||
const PROVIDER_NAME: &str = "Clipboard";
|
||||
const PROVIDER_PREFIX: &str = ":clip";
|
||||
const PROVIDER_ICON: &str = "edit-paste";
|
||||
const PROVIDER_TYPE_ID: &str = "clipboard";
|
||||
|
||||
// Default max entries to show
|
||||
const DEFAULT_MAX_ENTRIES: usize = 50;
|
||||
|
||||
/// Clipboard provider state - holds cached items
|
||||
struct ClipboardState {
|
||||
items: Vec<PluginItem>,
|
||||
max_entries: usize,
|
||||
}
|
||||
|
||||
impl ClipboardState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
items: Vec::new(),
|
||||
max_entries: DEFAULT_MAX_ENTRIES,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if cliphist is available
|
||||
fn has_cliphist() -> bool {
|
||||
Command::new("which")
|
||||
.arg("cliphist")
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn load_clipboard_history(&mut self) {
|
||||
self.items.clear();
|
||||
|
||||
if !Self::has_cliphist() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get clipboard history from cliphist
|
||||
let output = match Command::new("cliphist").arg("list").output() {
|
||||
Ok(o) => o,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
if !output.status.success() {
|
||||
return;
|
||||
}
|
||||
|
||||
let content = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
for (idx, line) in content.lines().take(self.max_entries).enumerate() {
|
||||
// cliphist format: "id\tpreview"
|
||||
let parts: Vec<&str> = line.splitn(2, '\t').collect();
|
||||
|
||||
if parts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let clip_id = parts[0];
|
||||
let preview = if parts.len() > 1 {
|
||||
// Truncate long previews (char-safe for UTF-8)
|
||||
let p = parts[1];
|
||||
if p.chars().count() > 80 {
|
||||
let truncated: String = p.chars().take(77).collect();
|
||||
format!("{}...", truncated)
|
||||
} else {
|
||||
p.to_string()
|
||||
}
|
||||
} else {
|
||||
"[binary data]".to_string()
|
||||
};
|
||||
|
||||
// Clean up preview - replace newlines with spaces
|
||||
let preview_clean = preview
|
||||
.replace('\n', " ")
|
||||
.replace('\r', "")
|
||||
.replace('\t', " ");
|
||||
|
||||
// Command to paste this entry
|
||||
// echo "id" | cliphist decode | wl-copy
|
||||
let command = format!(
|
||||
"echo '{}' | cliphist decode | wl-copy",
|
||||
clip_id.replace('\'', "'\\''")
|
||||
);
|
||||
|
||||
self.items.push(
|
||||
PluginItem::new(format!("clipboard:{}", idx), preview_clean, command)
|
||||
.with_description("Copy to clipboard")
|
||||
.with_icon(PROVIDER_ICON),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Interface Implementation
|
||||
// ============================================================================
|
||||
|
||||
extern "C" fn plugin_info() -> PluginInfo {
|
||||
PluginInfo {
|
||||
id: RString::from(PLUGIN_ID),
|
||||
name: RString::from(PLUGIN_NAME),
|
||||
version: RString::from(PLUGIN_VERSION),
|
||||
description: RString::from(PLUGIN_DESCRIPTION),
|
||||
api_version: API_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
|
||||
vec![ProviderInfo {
|
||||
id: RString::from(PROVIDER_ID),
|
||||
name: RString::from(PROVIDER_NAME),
|
||||
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
|
||||
icon: RString::from(PROVIDER_ICON),
|
||||
provider_type: ProviderKind::Static,
|
||||
type_id: RString::from(PROVIDER_TYPE_ID),
|
||||
position: ProviderPosition::Normal,
|
||||
priority: 0, // Static: use frecency ordering
|
||||
}]
|
||||
.into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
|
||||
let state = Box::new(ClipboardState::new());
|
||||
ProviderHandle::from_box(state)
|
||||
}
|
||||
|
||||
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
|
||||
if handle.ptr.is_null() {
|
||||
return RVec::new();
|
||||
}
|
||||
|
||||
// SAFETY: We created this handle from Box<ClipboardState>
|
||||
let state = unsafe { &mut *(handle.ptr as *mut ClipboardState) };
|
||||
|
||||
// Load clipboard history
|
||||
state.load_clipboard_history();
|
||||
|
||||
// Return items
|
||||
state.items.to_vec().into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec<PluginItem> {
|
||||
// Static provider - query is handled by the core using cached items
|
||||
RVec::new()
|
||||
}
|
||||
|
||||
extern "C" fn provider_drop(handle: ProviderHandle) {
|
||||
if !handle.ptr.is_null() {
|
||||
// SAFETY: We created this handle from Box<ClipboardState>
|
||||
unsafe {
|
||||
handle.drop_as::<ClipboardState>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the plugin vtable
|
||||
owlry_plugin! {
|
||||
info: plugin_info,
|
||||
providers: plugin_providers,
|
||||
init: provider_init,
|
||||
refresh: provider_refresh,
|
||||
query: provider_query,
|
||||
drop: provider_drop,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_clipboard_state_new() {
|
||||
let state = ClipboardState::new();
|
||||
assert!(state.items.is_empty());
|
||||
assert_eq!(state.max_entries, DEFAULT_MAX_ENTRIES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preview_truncation() {
|
||||
// Test that long strings would be truncated (char-safe)
|
||||
let long_text = "a".repeat(100);
|
||||
let truncated = if long_text.chars().count() > 80 {
|
||||
let t: String = long_text.chars().take(77).collect();
|
||||
format!("{}...", t)
|
||||
} else {
|
||||
long_text.clone()
|
||||
};
|
||||
assert_eq!(truncated.chars().count(), 80);
|
||||
assert!(truncated.ends_with("..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preview_truncation_utf8() {
|
||||
// Test with multi-byte UTF-8 characters (box-drawing chars are 3 bytes each)
|
||||
let utf8_text = "├── ".repeat(30); // Each "├── " is 7 bytes but 4 chars
|
||||
let truncated = if utf8_text.chars().count() > 80 {
|
||||
let t: String = utf8_text.chars().take(77).collect();
|
||||
format!("{}...", t)
|
||||
} else {
|
||||
utf8_text.clone()
|
||||
};
|
||||
assert_eq!(truncated.chars().count(), 80);
|
||||
assert!(truncated.ends_with("..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preview_cleaning() {
|
||||
let dirty = "line1\nline2\tcolumn\rend";
|
||||
let clean = dirty
|
||||
.replace('\n', " ")
|
||||
.replace('\r', "")
|
||||
.replace('\t', " ");
|
||||
assert_eq!(clean, "line1 line2 columnend");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_escaping() {
|
||||
let clip_id = "test'id";
|
||||
let command = format!(
|
||||
"echo '{}' | cliphist decode | wl-copy",
|
||||
clip_id.replace('\'', "'\\''")
|
||||
);
|
||||
assert!(command.contains("test'\\''id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_has_cliphist_runs() {
|
||||
// Just ensure it doesn't panic - cliphist may or may not be installed
|
||||
let _ = ClipboardState::has_cliphist();
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
[package]
|
||||
name = "owlry-plugin-emoji"
|
||||
version = "0.4.10"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Emoji plugin for owlry - search and copy emojis"
|
||||
keywords = ["owlry", "plugin", "emoji"]
|
||||
categories = ["text-processing"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
@@ -1,565 +0,0 @@
|
||||
//! Emoji Plugin for Owlry
|
||||
//!
|
||||
//! A static provider that provides emoji search and copy functionality.
|
||||
//! Requires wl-clipboard (wl-copy) for copying to clipboard.
|
||||
//!
|
||||
//! Examples:
|
||||
//! - Search "smile" → 😀 😃 😄 etc.
|
||||
//! - Search "heart" → ❤️ 💙 💚 etc.
|
||||
|
||||
use abi_stable::std_types::{ROption, RStr, RString, RVec};
|
||||
use owlry_plugin_api::{
|
||||
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
|
||||
ProviderPosition, API_VERSION,
|
||||
};
|
||||
|
||||
// Plugin metadata
|
||||
const PLUGIN_ID: &str = "emoji";
|
||||
const PLUGIN_NAME: &str = "Emoji";
|
||||
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const PLUGIN_DESCRIPTION: &str = "Search and copy emojis";
|
||||
|
||||
// Provider metadata
|
||||
const PROVIDER_ID: &str = "emoji";
|
||||
const PROVIDER_NAME: &str = "Emoji";
|
||||
const PROVIDER_PREFIX: &str = ":emoji";
|
||||
const PROVIDER_ICON: &str = "face-smile";
|
||||
const PROVIDER_TYPE_ID: &str = "emoji";
|
||||
|
||||
/// Emoji provider state - holds cached items
|
||||
struct EmojiState {
|
||||
items: Vec<PluginItem>,
|
||||
}
|
||||
|
||||
impl EmojiState {
|
||||
fn new() -> Self {
|
||||
Self { items: Vec::new() }
|
||||
}
|
||||
|
||||
fn load_emojis(&mut self) {
|
||||
self.items.clear();
|
||||
|
||||
// Common emojis with searchable names
|
||||
// Format: (emoji, name, keywords)
|
||||
let emojis: &[(&str, &str, &str)] = &[
|
||||
// Smileys & Emotion
|
||||
("😀", "grinning face", "smile happy"),
|
||||
("😃", "grinning face with big eyes", "smile happy"),
|
||||
("😄", "grinning face with smiling eyes", "smile happy laugh"),
|
||||
("😁", "beaming face with smiling eyes", "smile happy grin"),
|
||||
("😅", "grinning face with sweat", "smile nervous"),
|
||||
("🤣", "rolling on the floor laughing", "lol rofl funny"),
|
||||
("😂", "face with tears of joy", "laugh cry funny lol"),
|
||||
("🙂", "slightly smiling face", "smile"),
|
||||
("😊", "smiling face with smiling eyes", "blush happy"),
|
||||
("😇", "smiling face with halo", "angel innocent"),
|
||||
("🥰", "smiling face with hearts", "love adore"),
|
||||
("😍", "smiling face with heart-eyes", "love crush"),
|
||||
("🤩", "star-struck", "excited wow amazing"),
|
||||
("😘", "face blowing a kiss", "kiss love"),
|
||||
("😜", "winking face with tongue", "playful silly"),
|
||||
("🤪", "zany face", "crazy silly wild"),
|
||||
("😎", "smiling face with sunglasses", "cool"),
|
||||
("🤓", "nerd face", "geek glasses"),
|
||||
("🧐", "face with monocle", "thinking inspect"),
|
||||
("😏", "smirking face", "smug"),
|
||||
("😒", "unamused face", "meh annoyed"),
|
||||
("🙄", "face with rolling eyes", "whatever annoyed"),
|
||||
("😬", "grimacing face", "awkward nervous"),
|
||||
("😮💨", "face exhaling", "sigh relief"),
|
||||
("🤥", "lying face", "pinocchio lie"),
|
||||
("😌", "relieved face", "relaxed peaceful"),
|
||||
("😔", "pensive face", "sad thoughtful"),
|
||||
("😪", "sleepy face", "tired"),
|
||||
("🤤", "drooling face", "hungry yummy"),
|
||||
("😴", "sleeping face", "zzz tired"),
|
||||
("😷", "face with medical mask", "sick covid"),
|
||||
("🤒", "face with thermometer", "sick fever"),
|
||||
("🤕", "face with head-bandage", "hurt injured"),
|
||||
("🤢", "nauseated face", "sick gross"),
|
||||
("🤮", "face vomiting", "sick puke"),
|
||||
("🤧", "sneezing face", "achoo sick"),
|
||||
("🥵", "hot face", "sweating heat"),
|
||||
("🥶", "cold face", "freezing"),
|
||||
("😵", "face with crossed-out eyes", "dizzy dead"),
|
||||
("🤯", "exploding head", "mind blown wow"),
|
||||
("🤠", "cowboy hat face", "yeehaw western"),
|
||||
("🥳", "partying face", "celebration party"),
|
||||
("🥸", "disguised face", "incognito"),
|
||||
("🤡", "clown face", "circus"),
|
||||
("👻", "ghost", "halloween spooky"),
|
||||
("💀", "skull", "dead death"),
|
||||
("☠️", "skull and crossbones", "danger death"),
|
||||
("👽", "alien", "ufo extraterrestrial"),
|
||||
("🤖", "robot", "bot android"),
|
||||
("💩", "pile of poo", "poop"),
|
||||
("😈", "smiling face with horns", "devil evil"),
|
||||
("👿", "angry face with horns", "devil evil"),
|
||||
// Gestures & People
|
||||
("👋", "waving hand", "hello hi bye wave"),
|
||||
("🤚", "raised back of hand", "stop"),
|
||||
("🖐️", "hand with fingers splayed", "five high"),
|
||||
("✋", "raised hand", "stop high five"),
|
||||
("🖖", "vulcan salute", "spock trek"),
|
||||
("👌", "ok hand", "okay perfect"),
|
||||
("🤌", "pinched fingers", "italian"),
|
||||
("🤏", "pinching hand", "small tiny"),
|
||||
("✌️", "victory hand", "peace two"),
|
||||
("🤞", "crossed fingers", "luck hope"),
|
||||
("🤟", "love-you gesture", "ily rock"),
|
||||
("🤘", "sign of the horns", "rock metal"),
|
||||
("🤙", "call me hand", "shaka hang loose"),
|
||||
("👈", "backhand index pointing left", "left point"),
|
||||
("👉", "backhand index pointing right", "right point"),
|
||||
("👆", "backhand index pointing up", "up point"),
|
||||
("👇", "backhand index pointing down", "down point"),
|
||||
("☝️", "index pointing up", "one point"),
|
||||
("👍", "thumbs up", "like yes good approve"),
|
||||
("👎", "thumbs down", "dislike no bad"),
|
||||
("✊", "raised fist", "power solidarity"),
|
||||
("👊", "oncoming fist", "punch bump"),
|
||||
("🤛", "left-facing fist", "fist bump"),
|
||||
("🤜", "right-facing fist", "fist bump"),
|
||||
("👏", "clapping hands", "applause bravo"),
|
||||
("🙌", "raising hands", "hooray celebrate"),
|
||||
("👐", "open hands", "hug"),
|
||||
("🤲", "palms up together", "prayer"),
|
||||
("🤝", "handshake", "agreement deal"),
|
||||
("🙏", "folded hands", "prayer please thanks"),
|
||||
("✍️", "writing hand", "write"),
|
||||
("💪", "flexed biceps", "strong muscle"),
|
||||
("🦾", "mechanical arm", "robot prosthetic"),
|
||||
("🦵", "leg", "kick"),
|
||||
("🦶", "foot", "kick"),
|
||||
("👂", "ear", "listen hear"),
|
||||
("👃", "nose", "smell"),
|
||||
("🧠", "brain", "smart think"),
|
||||
("👀", "eyes", "look see watch"),
|
||||
("👁️", "eye", "see look"),
|
||||
("👅", "tongue", "taste lick"),
|
||||
("👄", "mouth", "lips kiss"),
|
||||
// Hearts & Love
|
||||
("❤️", "red heart", "love"),
|
||||
("🧡", "orange heart", "love"),
|
||||
("💛", "yellow heart", "love friendship"),
|
||||
("💚", "green heart", "love"),
|
||||
("💙", "blue heart", "love"),
|
||||
("💜", "purple heart", "love"),
|
||||
("🖤", "black heart", "love dark"),
|
||||
("🤍", "white heart", "love pure"),
|
||||
("🤎", "brown heart", "love"),
|
||||
("💔", "broken heart", "heartbreak sad"),
|
||||
("❤️🔥", "heart on fire", "passion love"),
|
||||
("❤️🩹", "mending heart", "healing recovery"),
|
||||
("💕", "two hearts", "love"),
|
||||
("💞", "revolving hearts", "love"),
|
||||
("💓", "beating heart", "love"),
|
||||
("💗", "growing heart", "love"),
|
||||
("💖", "sparkling heart", "love"),
|
||||
("💘", "heart with arrow", "love cupid"),
|
||||
("💝", "heart with ribbon", "love gift"),
|
||||
("💟", "heart decoration", "love"),
|
||||
// Animals
|
||||
("🐶", "dog face", "puppy"),
|
||||
("🐱", "cat face", "kitty"),
|
||||
("🐭", "mouse face", ""),
|
||||
("🐹", "hamster", ""),
|
||||
("🐰", "rabbit face", "bunny"),
|
||||
("🦊", "fox", ""),
|
||||
("🐻", "bear", ""),
|
||||
("🐼", "panda", ""),
|
||||
("🐨", "koala", ""),
|
||||
("🐯", "tiger face", ""),
|
||||
("🦁", "lion", ""),
|
||||
("🐮", "cow face", ""),
|
||||
("🐷", "pig face", ""),
|
||||
("🐸", "frog", ""),
|
||||
("🐵", "monkey face", ""),
|
||||
("🦄", "unicorn", "magic"),
|
||||
("🐝", "bee", "honeybee"),
|
||||
("🦋", "butterfly", ""),
|
||||
("🐌", "snail", "slow"),
|
||||
("🐛", "bug", "caterpillar"),
|
||||
("🦀", "crab", ""),
|
||||
("🐙", "octopus", ""),
|
||||
("🐠", "tropical fish", ""),
|
||||
("🐟", "fish", ""),
|
||||
("🐬", "dolphin", ""),
|
||||
("🐳", "whale", ""),
|
||||
("🦈", "shark", ""),
|
||||
("🐊", "crocodile", "alligator"),
|
||||
("🐢", "turtle", ""),
|
||||
("🦎", "lizard", ""),
|
||||
("🐍", "snake", ""),
|
||||
("🦖", "t-rex", "dinosaur"),
|
||||
("🦕", "sauropod", "dinosaur"),
|
||||
("🐔", "chicken", ""),
|
||||
("🐧", "penguin", ""),
|
||||
("🦅", "eagle", "bird"),
|
||||
("🦆", "duck", ""),
|
||||
("🦉", "owl", ""),
|
||||
// Food & Drink
|
||||
("🍎", "red apple", "fruit"),
|
||||
("🍐", "pear", "fruit"),
|
||||
("🍊", "orange", "tangerine fruit"),
|
||||
("🍋", "lemon", "fruit"),
|
||||
("🍌", "banana", "fruit"),
|
||||
("🍉", "watermelon", "fruit"),
|
||||
("🍇", "grapes", "fruit"),
|
||||
("🍓", "strawberry", "fruit"),
|
||||
("🍒", "cherries", "fruit"),
|
||||
("🍑", "peach", "fruit"),
|
||||
("🥭", "mango", "fruit"),
|
||||
("🍍", "pineapple", "fruit"),
|
||||
("🥥", "coconut", "fruit"),
|
||||
("🥝", "kiwi", "fruit"),
|
||||
("🍅", "tomato", "vegetable"),
|
||||
("🥑", "avocado", ""),
|
||||
("🥦", "broccoli", "vegetable"),
|
||||
("🥬", "leafy green", "vegetable salad"),
|
||||
("🥒", "cucumber", "vegetable"),
|
||||
("🌶️", "hot pepper", "spicy chili"),
|
||||
("🌽", "corn", ""),
|
||||
("🥕", "carrot", "vegetable"),
|
||||
("🧄", "garlic", ""),
|
||||
("🧅", "onion", ""),
|
||||
("🥔", "potato", ""),
|
||||
("🍞", "bread", ""),
|
||||
("🥐", "croissant", ""),
|
||||
("🥖", "baguette", "bread french"),
|
||||
("🥨", "pretzel", ""),
|
||||
("🧀", "cheese", ""),
|
||||
("🥚", "egg", ""),
|
||||
("🍳", "cooking", "frying pan egg"),
|
||||
("🥞", "pancakes", "breakfast"),
|
||||
("🧇", "waffle", "breakfast"),
|
||||
("🥓", "bacon", "breakfast"),
|
||||
("🍔", "hamburger", "burger"),
|
||||
("🍟", "french fries", ""),
|
||||
("🍕", "pizza", ""),
|
||||
("🌭", "hot dog", ""),
|
||||
("🥪", "sandwich", ""),
|
||||
("🌮", "taco", "mexican"),
|
||||
("🌯", "burrito", "mexican"),
|
||||
("🍜", "steaming bowl", "ramen noodles"),
|
||||
("🍝", "spaghetti", "pasta"),
|
||||
("🍣", "sushi", "japanese"),
|
||||
("🍱", "bento box", "japanese"),
|
||||
("🍩", "doughnut", "donut dessert"),
|
||||
("🍪", "cookie", "dessert"),
|
||||
("🎂", "birthday cake", "dessert"),
|
||||
("🍰", "shortcake", "dessert"),
|
||||
("🧁", "cupcake", "dessert"),
|
||||
("🍫", "chocolate bar", "dessert"),
|
||||
("🍬", "candy", "sweet"),
|
||||
("🍭", "lollipop", "candy sweet"),
|
||||
("🍦", "soft ice cream", "dessert"),
|
||||
("🍨", "ice cream", "dessert"),
|
||||
("☕", "hot beverage", "coffee tea"),
|
||||
("🍵", "teacup", "tea"),
|
||||
("🧃", "juice box", ""),
|
||||
("🥤", "cup with straw", "soda drink"),
|
||||
("🍺", "beer mug", "drink alcohol"),
|
||||
("🍻", "clinking beer mugs", "cheers drink"),
|
||||
("🥂", "clinking glasses", "champagne cheers"),
|
||||
("🍷", "wine glass", "drink alcohol"),
|
||||
("🥃", "tumbler glass", "whiskey drink"),
|
||||
("🍸", "cocktail glass", "martini drink"),
|
||||
// Objects & Symbols
|
||||
("💻", "laptop", "computer"),
|
||||
("🖥️", "desktop computer", "pc"),
|
||||
("⌨️", "keyboard", ""),
|
||||
("🖱️", "computer mouse", ""),
|
||||
("💾", "floppy disk", "save"),
|
||||
("💿", "optical disk", "cd"),
|
||||
("📱", "mobile phone", "smartphone"),
|
||||
("☎️", "telephone", "phone"),
|
||||
("📧", "email", "mail"),
|
||||
("📨", "incoming envelope", "email"),
|
||||
("📩", "envelope with arrow", "email send"),
|
||||
("📝", "memo", "note write"),
|
||||
("📄", "page facing up", "document"),
|
||||
("📃", "page with curl", "document"),
|
||||
("📑", "bookmark tabs", ""),
|
||||
("📚", "books", "library read"),
|
||||
("📖", "open book", "read"),
|
||||
("🔗", "link", "chain url"),
|
||||
("📎", "paperclip", "attachment"),
|
||||
("🔒", "locked", "security"),
|
||||
("🔓", "unlocked", "security open"),
|
||||
("🔑", "key", "password"),
|
||||
("🔧", "wrench", "tool fix"),
|
||||
("🔨", "hammer", "tool"),
|
||||
("⚙️", "gear", "settings"),
|
||||
("🧲", "magnet", ""),
|
||||
("💡", "light bulb", "idea"),
|
||||
("🔦", "flashlight", ""),
|
||||
("🔋", "battery", "power"),
|
||||
("🔌", "electric plug", "power"),
|
||||
("💰", "money bag", ""),
|
||||
("💵", "dollar", "money cash"),
|
||||
("💳", "credit card", "payment"),
|
||||
("⏰", "alarm clock", "time"),
|
||||
("⏱️", "stopwatch", "timer"),
|
||||
("📅", "calendar", "date"),
|
||||
("📆", "tear-off calendar", "date"),
|
||||
("✅", "check mark", "done yes"),
|
||||
("❌", "cross mark", "no wrong delete"),
|
||||
("❓", "question mark", "help"),
|
||||
("❗", "exclamation mark", "important warning"),
|
||||
("⚠️", "warning", "caution alert"),
|
||||
("🚫", "prohibited", "no ban forbidden"),
|
||||
("⭕", "hollow circle", ""),
|
||||
("🔴", "red circle", ""),
|
||||
("🟠", "orange circle", ""),
|
||||
("🟡", "yellow circle", ""),
|
||||
("🟢", "green circle", ""),
|
||||
("🔵", "blue circle", ""),
|
||||
("🟣", "purple circle", ""),
|
||||
("⚫", "black circle", ""),
|
||||
("⚪", "white circle", ""),
|
||||
("🟤", "brown circle", ""),
|
||||
("⬛", "black square", ""),
|
||||
("⬜", "white square", ""),
|
||||
("🔶", "large orange diamond", ""),
|
||||
("🔷", "large blue diamond", ""),
|
||||
("⭐", "star", "favorite"),
|
||||
("🌟", "glowing star", "sparkle"),
|
||||
("✨", "sparkles", "magic shine"),
|
||||
("💫", "dizzy", "star"),
|
||||
("🔥", "fire", "hot lit"),
|
||||
("💧", "droplet", "water"),
|
||||
("🌊", "wave", "water ocean"),
|
||||
("🎵", "musical note", "music"),
|
||||
("🎶", "musical notes", "music"),
|
||||
("🎤", "microphone", "sing karaoke"),
|
||||
("🎧", "headphones", "music"),
|
||||
("🎮", "video game", "gaming controller"),
|
||||
("🕹️", "joystick", "gaming"),
|
||||
("🎯", "direct hit", "target bullseye"),
|
||||
("🏆", "trophy", "winner award"),
|
||||
("🥇", "1st place medal", "gold winner"),
|
||||
("🥈", "2nd place medal", "silver"),
|
||||
("🥉", "3rd place medal", "bronze"),
|
||||
("🎁", "wrapped gift", "present"),
|
||||
("🎈", "balloon", "party"),
|
||||
("🎉", "party popper", "celebration tada"),
|
||||
("🎊", "confetti ball", "celebration"),
|
||||
// Arrows & Misc
|
||||
("➡️", "right arrow", ""),
|
||||
("⬅️", "left arrow", ""),
|
||||
("⬆️", "up arrow", ""),
|
||||
("⬇️", "down arrow", ""),
|
||||
("↗️", "up-right arrow", ""),
|
||||
("↘️", "down-right arrow", ""),
|
||||
("↙️", "down-left arrow", ""),
|
||||
("↖️", "up-left arrow", ""),
|
||||
("↕️", "up-down arrow", ""),
|
||||
("↔️", "left-right arrow", ""),
|
||||
("🔄", "counterclockwise arrows", "refresh reload"),
|
||||
("🔃", "clockwise arrows", "refresh reload"),
|
||||
("➕", "plus", "add"),
|
||||
("➖", "minus", "subtract"),
|
||||
("➗", "division", "divide"),
|
||||
("✖️", "multiply", "times"),
|
||||
("♾️", "infinity", "forever"),
|
||||
("💯", "hundred points", "100 perfect"),
|
||||
("🆗", "ok button", "okay"),
|
||||
("🆕", "new button", ""),
|
||||
("🆓", "free button", ""),
|
||||
("ℹ️", "information", "info"),
|
||||
("🅿️", "parking", ""),
|
||||
("🚀", "rocket", "launch startup"),
|
||||
("✈️", "airplane", "travel flight"),
|
||||
("🚗", "car", "automobile"),
|
||||
("🚕", "taxi", "cab"),
|
||||
("🚌", "bus", ""),
|
||||
("🚂", "locomotive", "train"),
|
||||
("🏠", "house", "home"),
|
||||
("🏢", "office building", "work"),
|
||||
("🏥", "hospital", ""),
|
||||
("🏫", "school", ""),
|
||||
("🏛️", "classical building", ""),
|
||||
("⛪", "church", ""),
|
||||
("🕌", "mosque", ""),
|
||||
("🕍", "synagogue", ""),
|
||||
("🗽", "statue of liberty", "usa america"),
|
||||
("🗼", "tokyo tower", "japan"),
|
||||
("🗾", "map of japan", ""),
|
||||
("🌍", "globe europe-africa", "earth world"),
|
||||
("🌎", "globe americas", "earth world"),
|
||||
("🌏", "globe asia-australia", "earth world"),
|
||||
("🌑", "new moon", ""),
|
||||
("🌕", "full moon", ""),
|
||||
("☀️", "sun", "sunny"),
|
||||
("🌙", "crescent moon", "night"),
|
||||
("☁️", "cloud", ""),
|
||||
("🌧️", "cloud with rain", "rainy"),
|
||||
("⛈️", "cloud with lightning", "storm thunder"),
|
||||
("🌈", "rainbow", ""),
|
||||
("❄️", "snowflake", "cold winter"),
|
||||
("☃️", "snowman", "winter"),
|
||||
("🎄", "christmas tree", "xmas holiday"),
|
||||
("🎃", "jack-o-lantern", "halloween pumpkin"),
|
||||
("🐚", "shell", "beach"),
|
||||
("🌸", "cherry blossom", "flower spring"),
|
||||
("🌺", "hibiscus", "flower"),
|
||||
("🌻", "sunflower", "flower"),
|
||||
("🌹", "rose", "flower love"),
|
||||
("🌷", "tulip", "flower"),
|
||||
("🌱", "seedling", "plant grow"),
|
||||
("🌲", "evergreen tree", ""),
|
||||
("🌳", "deciduous tree", ""),
|
||||
("🌴", "palm tree", "tropical"),
|
||||
("🌵", "cactus", "desert"),
|
||||
("🍀", "four leaf clover", "luck irish"),
|
||||
("🍁", "maple leaf", "fall autumn canada"),
|
||||
("🍂", "fallen leaf", "fall autumn"),
|
||||
];
|
||||
|
||||
for (emoji, name, keywords) in emojis {
|
||||
self.items.push(
|
||||
PluginItem::new(
|
||||
format!("emoji:{}", emoji),
|
||||
name.to_string(),
|
||||
format!("printf '%s' '{}' | wl-copy", emoji),
|
||||
)
|
||||
.with_icon(*emoji) // Use emoji character as icon
|
||||
.with_description(format!("{} {}", emoji, keywords))
|
||||
.with_keywords(vec![name.to_string(), keywords.to_string()]),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Interface Implementation
|
||||
// ============================================================================
|
||||
|
||||
extern "C" fn plugin_info() -> PluginInfo {
|
||||
PluginInfo {
|
||||
id: RString::from(PLUGIN_ID),
|
||||
name: RString::from(PLUGIN_NAME),
|
||||
version: RString::from(PLUGIN_VERSION),
|
||||
description: RString::from(PLUGIN_DESCRIPTION),
|
||||
api_version: API_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
|
||||
vec![ProviderInfo {
|
||||
id: RString::from(PROVIDER_ID),
|
||||
name: RString::from(PROVIDER_NAME),
|
||||
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
|
||||
icon: RString::from(PROVIDER_ICON),
|
||||
provider_type: ProviderKind::Static,
|
||||
type_id: RString::from(PROVIDER_TYPE_ID),
|
||||
position: ProviderPosition::Normal,
|
||||
priority: 0, // Static: use frecency ordering
|
||||
}]
|
||||
.into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
|
||||
let state = Box::new(EmojiState::new());
|
||||
ProviderHandle::from_box(state)
|
||||
}
|
||||
|
||||
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
|
||||
if handle.ptr.is_null() {
|
||||
return RVec::new();
|
||||
}
|
||||
|
||||
// SAFETY: We created this handle from Box<EmojiState>
|
||||
let state = unsafe { &mut *(handle.ptr as *mut EmojiState) };
|
||||
|
||||
// Load emojis
|
||||
state.load_emojis();
|
||||
|
||||
// Return items
|
||||
state.items.to_vec().into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec<PluginItem> {
|
||||
// Static provider - query is handled by the core using cached items
|
||||
RVec::new()
|
||||
}
|
||||
|
||||
extern "C" fn provider_drop(handle: ProviderHandle) {
|
||||
if !handle.ptr.is_null() {
|
||||
// SAFETY: We created this handle from Box<EmojiState>
|
||||
unsafe {
|
||||
handle.drop_as::<EmojiState>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the plugin vtable
|
||||
owlry_plugin! {
|
||||
info: plugin_info,
|
||||
providers: plugin_providers,
|
||||
init: provider_init,
|
||||
refresh: provider_refresh,
|
||||
query: provider_query,
|
||||
drop: provider_drop,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_emoji_state_new() {
|
||||
let state = EmojiState::new();
|
||||
assert!(state.items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emoji_count() {
|
||||
let mut state = EmojiState::new();
|
||||
state.load_emojis();
|
||||
assert!(state.items.len() > 100, "Should have more than 100 emojis");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emoji_has_grinning_face() {
|
||||
let mut state = EmojiState::new();
|
||||
state.load_emojis();
|
||||
|
||||
let grinning = state
|
||||
.items
|
||||
.iter()
|
||||
.find(|i| i.name.as_str() == "grinning face");
|
||||
assert!(grinning.is_some());
|
||||
|
||||
let item = grinning.unwrap();
|
||||
assert!(item.description.as_ref().unwrap().as_str().contains("😀"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emoji_command_format() {
|
||||
let mut state = EmojiState::new();
|
||||
state.load_emojis();
|
||||
|
||||
let item = &state.items[0];
|
||||
assert!(item.command.as_str().contains("wl-copy"));
|
||||
assert!(item.command.as_str().contains("printf"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emojis_have_keywords() {
|
||||
let mut state = EmojiState::new();
|
||||
state.load_emojis();
|
||||
|
||||
// Check that items have keywords for searching
|
||||
let heart = state
|
||||
.items
|
||||
.iter()
|
||||
.find(|i| i.name.as_str() == "red heart");
|
||||
assert!(heart.is_some());
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
[package]
|
||||
name = "owlry-plugin-filesearch"
|
||||
version = "0.4.10"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "File search plugin for owlry - find files with fd or locate"
|
||||
keywords = ["owlry", "plugin", "files", "search"]
|
||||
categories = ["filesystem"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
|
||||
# For finding home directory
|
||||
dirs = "5.0"
|
||||
@@ -1,322 +0,0 @@
|
||||
//! File Search Plugin for Owlry
|
||||
//!
|
||||
//! A dynamic provider that searches for files using `fd` or `locate`.
|
||||
//!
|
||||
//! Examples:
|
||||
//! - `/ config.toml` → Search for files matching "config.toml"
|
||||
//! - `file bashrc` → Search for files matching "bashrc"
|
||||
//! - `find readme` → Search for files matching "readme"
|
||||
//!
|
||||
//! Dependencies:
|
||||
//! - fd (preferred) or locate
|
||||
|
||||
use abi_stable::std_types::{ROption, RStr, RString, RVec};
|
||||
use owlry_plugin_api::{
|
||||
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
|
||||
ProviderPosition, API_VERSION,
|
||||
};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
// Plugin metadata
|
||||
const PLUGIN_ID: &str = "filesearch";
|
||||
const PLUGIN_NAME: &str = "File Search";
|
||||
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const PLUGIN_DESCRIPTION: &str = "Find files with fd or locate";
|
||||
|
||||
// Provider metadata
|
||||
const PROVIDER_ID: &str = "filesearch";
|
||||
const PROVIDER_NAME: &str = "Files";
|
||||
const PROVIDER_PREFIX: &str = "/";
|
||||
const PROVIDER_ICON: &str = "folder";
|
||||
const PROVIDER_TYPE_ID: &str = "filesearch";
|
||||
|
||||
// Maximum results to return
|
||||
const MAX_RESULTS: usize = 20;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum SearchTool {
|
||||
Fd,
|
||||
Locate,
|
||||
None,
|
||||
}
|
||||
|
||||
/// File search provider state
|
||||
struct FileSearchState {
|
||||
search_tool: SearchTool,
|
||||
home: String,
|
||||
}
|
||||
|
||||
impl FileSearchState {
|
||||
fn new() -> Self {
|
||||
let search_tool = Self::detect_search_tool();
|
||||
let home = dirs::home_dir()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "/".to_string());
|
||||
|
||||
Self { search_tool, home }
|
||||
}
|
||||
|
||||
fn detect_search_tool() -> SearchTool {
|
||||
// Prefer fd (faster, respects .gitignore)
|
||||
if Self::command_exists("fd") {
|
||||
return SearchTool::Fd;
|
||||
}
|
||||
// Fall back to locate (requires updatedb)
|
||||
if Self::command_exists("locate") {
|
||||
return SearchTool::Locate;
|
||||
}
|
||||
SearchTool::None
|
||||
}
|
||||
|
||||
fn command_exists(cmd: &str) -> bool {
|
||||
Command::new("which")
|
||||
.arg(cmd)
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Extract the search term from the query
|
||||
fn extract_search_term(query: &str) -> Option<&str> {
|
||||
let trimmed = query.trim();
|
||||
|
||||
if let Some(rest) = trimmed.strip_prefix("/ ") {
|
||||
Some(rest.trim())
|
||||
} else if let Some(rest) = trimmed.strip_prefix("/") {
|
||||
Some(rest.trim())
|
||||
} else {
|
||||
// Handle "file " and "find " prefixes (case-insensitive), or raw query in filter mode
|
||||
let lower = trimmed.to_lowercase();
|
||||
if lower.starts_with("file ") || lower.starts_with("find ") {
|
||||
Some(trimmed[5..].trim())
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate a query and return file results
|
||||
fn evaluate(&self, query: &str) -> Vec<PluginItem> {
|
||||
let search_term = match Self::extract_search_term(query) {
|
||||
Some(t) if !t.is_empty() => t,
|
||||
_ => return Vec::new(),
|
||||
};
|
||||
|
||||
self.search_files(search_term)
|
||||
}
|
||||
|
||||
fn search_files(&self, pattern: &str) -> Vec<PluginItem> {
|
||||
match self.search_tool {
|
||||
SearchTool::Fd => self.search_with_fd(pattern),
|
||||
SearchTool::Locate => self.search_with_locate(pattern),
|
||||
SearchTool::None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn search_with_fd(&self, pattern: &str) -> Vec<PluginItem> {
|
||||
let output = match Command::new("fd")
|
||||
.args([
|
||||
"--max-results",
|
||||
&MAX_RESULTS.to_string(),
|
||||
"--type",
|
||||
"f", // Files only
|
||||
"--type",
|
||||
"d", // And directories
|
||||
pattern,
|
||||
])
|
||||
.current_dir(&self.home)
|
||||
.output()
|
||||
{
|
||||
Ok(o) => o,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
self.parse_file_results(&String::from_utf8_lossy(&output.stdout))
|
||||
}
|
||||
|
||||
fn search_with_locate(&self, pattern: &str) -> Vec<PluginItem> {
|
||||
let output = match Command::new("locate")
|
||||
.args([
|
||||
"--limit",
|
||||
&MAX_RESULTS.to_string(),
|
||||
"--ignore-case",
|
||||
pattern,
|
||||
])
|
||||
.output()
|
||||
{
|
||||
Ok(o) => o,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
self.parse_file_results(&String::from_utf8_lossy(&output.stdout))
|
||||
}
|
||||
|
||||
fn parse_file_results(&self, output: &str) -> Vec<PluginItem> {
|
||||
output
|
||||
.lines()
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(|path| {
|
||||
let path = path.trim();
|
||||
let full_path = if path.starts_with('/') {
|
||||
path.to_string()
|
||||
} else {
|
||||
format!("{}/{}", self.home, path)
|
||||
};
|
||||
|
||||
// Get filename for display
|
||||
let filename = Path::new(&full_path)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| full_path.clone());
|
||||
|
||||
// Determine icon based on whether it's a directory
|
||||
let is_dir = Path::new(&full_path).is_dir();
|
||||
let icon = if is_dir { "folder" } else { "text-x-generic" };
|
||||
|
||||
// Command to open with xdg-open
|
||||
let command = format!("xdg-open '{}'", full_path.replace('\'', "'\\''"));
|
||||
|
||||
PluginItem::new(format!("file:{}", full_path), filename, command)
|
||||
.with_description(full_path.clone())
|
||||
.with_icon(icon)
|
||||
.with_keywords(vec!["file".to_string()])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Interface Implementation
|
||||
// ============================================================================
|
||||
|
||||
extern "C" fn plugin_info() -> PluginInfo {
|
||||
PluginInfo {
|
||||
id: RString::from(PLUGIN_ID),
|
||||
name: RString::from(PLUGIN_NAME),
|
||||
version: RString::from(PLUGIN_VERSION),
|
||||
description: RString::from(PLUGIN_DESCRIPTION),
|
||||
api_version: API_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
|
||||
vec![ProviderInfo {
|
||||
id: RString::from(PROVIDER_ID),
|
||||
name: RString::from(PROVIDER_NAME),
|
||||
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
|
||||
icon: RString::from(PROVIDER_ICON),
|
||||
provider_type: ProviderKind::Dynamic,
|
||||
type_id: RString::from(PROVIDER_TYPE_ID),
|
||||
position: ProviderPosition::Normal,
|
||||
priority: 8000, // Dynamic: file search
|
||||
}]
|
||||
.into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
|
||||
let state = Box::new(FileSearchState::new());
|
||||
ProviderHandle::from_box(state)
|
||||
}
|
||||
|
||||
extern "C" fn provider_refresh(_handle: ProviderHandle) -> RVec<PluginItem> {
|
||||
// Dynamic provider - refresh does nothing
|
||||
RVec::new()
|
||||
}
|
||||
|
||||
extern "C" fn provider_query(handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem> {
|
||||
if handle.ptr.is_null() {
|
||||
return RVec::new();
|
||||
}
|
||||
|
||||
// SAFETY: We created this handle from Box<FileSearchState>
|
||||
let state = unsafe { &*(handle.ptr as *const FileSearchState) };
|
||||
|
||||
let query_str = query.as_str();
|
||||
|
||||
state.evaluate(query_str).into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_drop(handle: ProviderHandle) {
|
||||
if !handle.ptr.is_null() {
|
||||
// SAFETY: We created this handle from Box<FileSearchState>
|
||||
unsafe {
|
||||
handle.drop_as::<FileSearchState>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the plugin vtable
|
||||
owlry_plugin! {
|
||||
info: plugin_info,
|
||||
providers: plugin_providers,
|
||||
init: provider_init,
|
||||
refresh: provider_refresh,
|
||||
query: provider_query,
|
||||
drop: provider_drop,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_search_term() {
|
||||
assert_eq!(
|
||||
FileSearchState::extract_search_term("/ config.toml"),
|
||||
Some("config.toml")
|
||||
);
|
||||
assert_eq!(
|
||||
FileSearchState::extract_search_term("/config"),
|
||||
Some("config")
|
||||
);
|
||||
assert_eq!(
|
||||
FileSearchState::extract_search_term("file bashrc"),
|
||||
Some("bashrc")
|
||||
);
|
||||
assert_eq!(
|
||||
FileSearchState::extract_search_term("find readme"),
|
||||
Some("readme")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_search_term_empty() {
|
||||
assert_eq!(FileSearchState::extract_search_term("/"), Some(""));
|
||||
assert_eq!(FileSearchState::extract_search_term("/ "), Some(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_exists() {
|
||||
// 'which' should exist on any Unix system
|
||||
assert!(FileSearchState::command_exists("which"));
|
||||
// This should not exist
|
||||
assert!(!FileSearchState::command_exists("nonexistent-command-12345"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_search_tool() {
|
||||
// Just ensure it doesn't panic
|
||||
let _ = FileSearchState::detect_search_tool();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_new() {
|
||||
let state = FileSearchState::new();
|
||||
assert!(!state.home.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_empty() {
|
||||
let state = FileSearchState::new();
|
||||
let results = state.evaluate("/");
|
||||
assert!(results.is_empty());
|
||||
|
||||
let results = state.evaluate("/ ");
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
[package]
|
||||
name = "owlry-plugin-media"
|
||||
version = "0.4.10"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "MPRIS media player widget plugin for owlry - shows and controls currently playing media. Requires playerctl."
|
||||
keywords = ["owlry", "plugin", "media", "mpris", "widget", "playerctl"]
|
||||
categories = ["gui"]
|
||||
|
||||
# System dependencies (for packagers):
|
||||
# - playerctl: for media control commands
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
@@ -1,468 +0,0 @@
|
||||
//! MPRIS Media Player Widget Plugin for Owlry
|
||||
//!
|
||||
//! Shows currently playing track as a single row with play/pause action.
|
||||
//! Uses D-Bus via dbus-send to communicate with MPRIS-compatible players.
|
||||
|
||||
use abi_stable::std_types::{ROption, RStr, RString, RVec};
|
||||
use owlry_plugin_api::{
|
||||
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
|
||||
ProviderPosition, API_VERSION,
|
||||
};
|
||||
use std::process::Command;
|
||||
|
||||
// Plugin metadata
|
||||
const PLUGIN_ID: &str = "media";
|
||||
const PLUGIN_NAME: &str = "Media Player";
|
||||
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const PLUGIN_DESCRIPTION: &str = "MPRIS media player widget - shows and controls currently playing media";
|
||||
|
||||
// Provider metadata
|
||||
const PROVIDER_ID: &str = "media";
|
||||
const PROVIDER_NAME: &str = "Media";
|
||||
const PROVIDER_ICON: &str = "applications-multimedia";
|
||||
const PROVIDER_TYPE_ID: &str = "media";
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
struct MediaState {
|
||||
player_name: String,
|
||||
title: String,
|
||||
artist: String,
|
||||
is_playing: bool,
|
||||
}
|
||||
|
||||
/// Media provider state
|
||||
struct MediaProviderState {
|
||||
items: Vec<PluginItem>,
|
||||
/// Current player name for submenu actions
|
||||
current_player: Option<String>,
|
||||
/// Current playback state
|
||||
is_playing: bool,
|
||||
}
|
||||
|
||||
impl MediaProviderState {
|
||||
fn new() -> Self {
|
||||
// Don't query D-Bus during init - defer to first refresh() call
|
||||
// This prevents blocking the main thread during startup
|
||||
Self {
|
||||
items: Vec::new(),
|
||||
current_player: None,
|
||||
is_playing: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh(&mut self) {
|
||||
self.items.clear();
|
||||
|
||||
let players = Self::find_players();
|
||||
if players.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find first active player
|
||||
for player in &players {
|
||||
if let Some(state) = Self::get_player_state(player) {
|
||||
self.generate_items(&state);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find active MPRIS players via dbus-send
|
||||
fn find_players() -> Vec<String> {
|
||||
let output = Command::new("dbus-send")
|
||||
.args([
|
||||
"--session",
|
||||
"--dest=org.freedesktop.DBus",
|
||||
"--type=method_call",
|
||||
"--print-reply",
|
||||
"/org/freedesktop/DBus",
|
||||
"org.freedesktop.DBus.ListNames",
|
||||
])
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(out) => {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
stdout
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("string \"org.mpris.MediaPlayer2.") {
|
||||
let start = "string \"org.mpris.MediaPlayer2.".len();
|
||||
let end = trimmed.len() - 1;
|
||||
Some(trimmed[start..end].to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get metadata from an MPRIS player
|
||||
fn get_player_state(player: &str) -> Option<MediaState> {
|
||||
let dest = format!("org.mpris.MediaPlayer2.{}", player);
|
||||
|
||||
// Get playback status
|
||||
let status_output = Command::new("dbus-send")
|
||||
.args([
|
||||
"--session",
|
||||
&format!("--dest={}", dest),
|
||||
"--type=method_call",
|
||||
"--print-reply",
|
||||
"/org/mpris/MediaPlayer2",
|
||||
"org.freedesktop.DBus.Properties.Get",
|
||||
"string:org.mpris.MediaPlayer2.Player",
|
||||
"string:PlaybackStatus",
|
||||
])
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
let status_str = String::from_utf8_lossy(&status_output.stdout);
|
||||
let is_playing = status_str.contains("\"Playing\"");
|
||||
let is_paused = status_str.contains("\"Paused\"");
|
||||
|
||||
// Only show if playing or paused (not stopped)
|
||||
if !is_playing && !is_paused {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Get metadata
|
||||
let metadata_output = Command::new("dbus-send")
|
||||
.args([
|
||||
"--session",
|
||||
&format!("--dest={}", dest),
|
||||
"--type=method_call",
|
||||
"--print-reply",
|
||||
"/org/mpris/MediaPlayer2",
|
||||
"org.freedesktop.DBus.Properties.Get",
|
||||
"string:org.mpris.MediaPlayer2.Player",
|
||||
"string:Metadata",
|
||||
])
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
let metadata_str = String::from_utf8_lossy(&metadata_output.stdout);
|
||||
|
||||
let title = Self::extract_string(&metadata_str, "xesam:title")
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
let artist = Self::extract_array(&metadata_str, "xesam:artist")
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
|
||||
Some(MediaState {
|
||||
player_name: player.to_string(),
|
||||
title,
|
||||
artist,
|
||||
is_playing,
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract string value from D-Bus output
|
||||
fn extract_string(output: &str, key: &str) -> Option<String> {
|
||||
let key_pattern = format!("\"{}\"", key);
|
||||
let mut found = false;
|
||||
|
||||
for line in output.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.contains(&key_pattern) {
|
||||
found = true;
|
||||
continue;
|
||||
}
|
||||
if found {
|
||||
if let Some(pos) = trimmed.find("string \"") {
|
||||
let start = pos + "string \"".len();
|
||||
if let Some(end) = trimmed[start..].find('"') {
|
||||
let value = &trimmed[start..start + end];
|
||||
if !value.is_empty() {
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if !trimmed.starts_with("variant") {
|
||||
found = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract array value from D-Bus output
|
||||
fn extract_array(output: &str, key: &str) -> Option<String> {
|
||||
let key_pattern = format!("\"{}\"", key);
|
||||
let mut found = false;
|
||||
let mut in_array = false;
|
||||
let mut values = Vec::new();
|
||||
|
||||
for line in output.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.contains(&key_pattern) {
|
||||
found = true;
|
||||
continue;
|
||||
}
|
||||
if found && trimmed.contains("array [") {
|
||||
in_array = true;
|
||||
continue;
|
||||
}
|
||||
if in_array {
|
||||
if let Some(pos) = trimmed.find("string \"") {
|
||||
let start = pos + "string \"".len();
|
||||
if let Some(end) = trimmed[start..].find('"') {
|
||||
values.push(trimmed[start..start + end].to_string());
|
||||
}
|
||||
}
|
||||
if trimmed.contains(']') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if values.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(values.join(", "))
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate single LaunchItem for media state (opens submenu)
|
||||
fn generate_items(&mut self, state: &MediaState) {
|
||||
self.items.clear();
|
||||
|
||||
// Store state for submenu
|
||||
self.current_player = Some(state.player_name.clone());
|
||||
self.is_playing = state.is_playing;
|
||||
|
||||
// Single row: "Title — Artist"
|
||||
let name = format!("{} — {}", state.title, state.artist);
|
||||
|
||||
// Extract player display name (e.g., "firefox.instance_1_94" -> "Firefox")
|
||||
let player_display = Self::format_player_name(&state.player_name);
|
||||
|
||||
// Opens submenu with media controls
|
||||
self.items.push(
|
||||
PluginItem::new("media-now-playing", name, "SUBMENU:media:controls")
|
||||
.with_description(format!("{} · Select for controls", player_display))
|
||||
.with_icon("/org/owlry/launcher/icons/media/music-note.svg")
|
||||
.with_keywords(vec!["media".to_string(), "widget".to_string()]),
|
||||
);
|
||||
}
|
||||
|
||||
/// Format player name for display
|
||||
fn format_player_name(player_name: &str) -> String {
|
||||
let player_display = player_name.split('.').next().unwrap_or(player_name);
|
||||
if player_display.is_empty() {
|
||||
"Player".to_string()
|
||||
} else {
|
||||
let mut chars = player_display.chars();
|
||||
match chars.next() {
|
||||
None => "Player".to_string(),
|
||||
Some(first) => first.to_uppercase().chain(chars).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate submenu items for media controls
|
||||
fn generate_submenu_items(&self) -> Vec<PluginItem> {
|
||||
let player = match &self.current_player {
|
||||
Some(p) => p,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
|
||||
let mut items = Vec::new();
|
||||
|
||||
// Use playerctl for simpler, more reliable media control
|
||||
// playerctl -p <player> <command>
|
||||
|
||||
// Play/Pause
|
||||
if self.is_playing {
|
||||
items.push(
|
||||
PluginItem::new(
|
||||
"media-pause",
|
||||
"Pause",
|
||||
format!("playerctl -p {} pause", player),
|
||||
)
|
||||
.with_description("Pause playback")
|
||||
.with_icon("media-playback-pause"),
|
||||
);
|
||||
} else {
|
||||
items.push(
|
||||
PluginItem::new(
|
||||
"media-play",
|
||||
"Play",
|
||||
format!("playerctl -p {} play", player),
|
||||
)
|
||||
.with_description("Resume playback")
|
||||
.with_icon("media-playback-start"),
|
||||
);
|
||||
}
|
||||
|
||||
// Next track
|
||||
items.push(
|
||||
PluginItem::new(
|
||||
"media-next",
|
||||
"Next",
|
||||
format!("playerctl -p {} next", player),
|
||||
)
|
||||
.with_description("Skip to next track")
|
||||
.with_icon("media-skip-forward"),
|
||||
);
|
||||
|
||||
// Previous track
|
||||
items.push(
|
||||
PluginItem::new(
|
||||
"media-previous",
|
||||
"Previous",
|
||||
format!("playerctl -p {} previous", player),
|
||||
)
|
||||
.with_description("Go to previous track")
|
||||
.with_icon("media-skip-backward"),
|
||||
);
|
||||
|
||||
// Stop
|
||||
items.push(
|
||||
PluginItem::new(
|
||||
"media-stop",
|
||||
"Stop",
|
||||
format!("playerctl -p {} stop", player),
|
||||
)
|
||||
.with_description("Stop playback")
|
||||
.with_icon("media-playback-stop"),
|
||||
);
|
||||
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Interface Implementation
|
||||
// ============================================================================
|
||||
|
||||
extern "C" fn plugin_info() -> PluginInfo {
|
||||
PluginInfo {
|
||||
id: RString::from(PLUGIN_ID),
|
||||
name: RString::from(PLUGIN_NAME),
|
||||
version: RString::from(PLUGIN_VERSION),
|
||||
description: RString::from(PLUGIN_DESCRIPTION),
|
||||
api_version: API_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
|
||||
vec![ProviderInfo {
|
||||
id: RString::from(PROVIDER_ID),
|
||||
name: RString::from(PROVIDER_NAME),
|
||||
prefix: ROption::RNone,
|
||||
icon: RString::from(PROVIDER_ICON),
|
||||
provider_type: ProviderKind::Static,
|
||||
type_id: RString::from(PROVIDER_TYPE_ID),
|
||||
position: ProviderPosition::Widget,
|
||||
priority: 11000, // Widget: media player
|
||||
}]
|
||||
.into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
|
||||
let state = Box::new(MediaProviderState::new());
|
||||
ProviderHandle::from_box(state)
|
||||
}
|
||||
|
||||
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
|
||||
if handle.ptr.is_null() {
|
||||
return RVec::new();
|
||||
}
|
||||
|
||||
// SAFETY: We created this handle from Box<MediaProviderState>
|
||||
let state = unsafe { &mut *(handle.ptr as *mut MediaProviderState) };
|
||||
|
||||
state.refresh();
|
||||
state.items.clone().into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_query(handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem> {
|
||||
if handle.ptr.is_null() {
|
||||
return RVec::new();
|
||||
}
|
||||
|
||||
let query_str = query.as_str();
|
||||
let state = unsafe { &*(handle.ptr as *const MediaProviderState) };
|
||||
|
||||
// Handle submenu request
|
||||
if query_str == "?SUBMENU:controls" {
|
||||
return state.generate_submenu_items().into();
|
||||
}
|
||||
|
||||
RVec::new()
|
||||
}
|
||||
|
||||
extern "C" fn provider_drop(handle: ProviderHandle) {
|
||||
if !handle.ptr.is_null() {
|
||||
// SAFETY: We created this handle from Box<MediaProviderState>
|
||||
unsafe {
|
||||
handle.drop_as::<MediaProviderState>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the plugin vtable
|
||||
owlry_plugin! {
|
||||
info: plugin_info,
|
||||
providers: plugin_providers,
|
||||
init: provider_init,
|
||||
refresh: provider_refresh,
|
||||
query: provider_query,
|
||||
drop: provider_drop,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_string() {
|
||||
let output = r#"
|
||||
string "xesam:title"
|
||||
variant string "My Song Title"
|
||||
"#;
|
||||
assert_eq!(
|
||||
MediaProviderState::extract_string(output, "xesam:title"),
|
||||
Some("My Song Title".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_array() {
|
||||
let output = r#"
|
||||
string "xesam:artist"
|
||||
variant array [
|
||||
string "Artist One"
|
||||
string "Artist Two"
|
||||
]
|
||||
"#;
|
||||
assert_eq!(
|
||||
MediaProviderState::extract_array(output, "xesam:artist"),
|
||||
Some("Artist One, Artist Two".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_string_not_found() {
|
||||
let output = "some other output";
|
||||
assert_eq!(
|
||||
MediaProviderState::extract_string(output, "xesam:title"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_players_empty() {
|
||||
// This will return empty on systems without D-Bus
|
||||
let players = MediaProviderState::find_players();
|
||||
// Just verify it doesn't panic
|
||||
let _ = players;
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
[package]
|
||||
name = "owlry-plugin-pomodoro"
|
||||
version = "0.4.10"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Pomodoro timer widget plugin for owlry - work/break cycles with persistent state"
|
||||
keywords = ["owlry", "plugin", "pomodoro", "timer", "widget"]
|
||||
categories = ["gui"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
|
||||
# JSON serialization for persistent state
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# TOML config parsing
|
||||
toml = "0.8"
|
||||
|
||||
# For finding data directory
|
||||
dirs = "5.0"
|
||||
@@ -1,478 +0,0 @@
|
||||
//! Pomodoro Timer Widget Plugin for Owlry
|
||||
//!
|
||||
//! Shows timer with work/break cycles. Select to open controls submenu.
|
||||
//! State persists across sessions via JSON file.
|
||||
//!
|
||||
//! ## Configuration
|
||||
//!
|
||||
//! Configure via `~/.config/owlry/config.toml`:
|
||||
//!
|
||||
//! ```toml
|
||||
//! [plugins.pomodoro]
|
||||
//! work_mins = 25 # Work session duration (default: 25)
|
||||
//! break_mins = 5 # Break duration (default: 5)
|
||||
//! ```
|
||||
|
||||
use abi_stable::std_types::{ROption, RStr, RString, RVec};
|
||||
use owlry_plugin_api::{
|
||||
notify_with_urgency, owlry_plugin, NotifyUrgency, PluginInfo, PluginItem, ProviderHandle,
|
||||
ProviderInfo, ProviderKind, ProviderPosition, API_VERSION,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
// Plugin metadata
|
||||
const PLUGIN_ID: &str = "pomodoro";
|
||||
const PLUGIN_NAME: &str = "Pomodoro Timer";
|
||||
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const PLUGIN_DESCRIPTION: &str = "Pomodoro timer widget with work/break cycles";
|
||||
|
||||
// Provider metadata
|
||||
const PROVIDER_ID: &str = "pomodoro";
|
||||
const PROVIDER_NAME: &str = "Pomodoro";
|
||||
const PROVIDER_ICON: &str = "alarm";
|
||||
const PROVIDER_TYPE_ID: &str = "pomodoro";
|
||||
|
||||
// Default timing (in minutes)
|
||||
const DEFAULT_WORK_MINS: u32 = 25;
|
||||
const DEFAULT_BREAK_MINS: u32 = 5;
|
||||
|
||||
/// Pomodoro configuration
|
||||
#[derive(Debug, Clone)]
|
||||
struct PomodoroConfig {
|
||||
work_mins: u32,
|
||||
break_mins: u32,
|
||||
}
|
||||
|
||||
impl PomodoroConfig {
|
||||
/// Load config from ~/.config/owlry/config.toml
|
||||
///
|
||||
/// Reads from [plugins.pomodoro] section, with fallback to [providers] for compatibility.
|
||||
fn load() -> Self {
|
||||
let config_path = dirs::config_dir()
|
||||
.map(|d| d.join("owlry").join("config.toml"));
|
||||
|
||||
let config_content = config_path
|
||||
.and_then(|p| fs::read_to_string(p).ok());
|
||||
|
||||
if let Some(content) = config_content
|
||||
&& let Ok(toml) = content.parse::<toml::Table>()
|
||||
{
|
||||
// Try [plugins.pomodoro] first (new format)
|
||||
if let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table())
|
||||
&& let Some(pomodoro) = plugins.get("pomodoro").and_then(|v| v.as_table())
|
||||
{
|
||||
return Self::from_toml_table(pomodoro);
|
||||
}
|
||||
|
||||
// Fallback to [providers] section (old format)
|
||||
if let Some(providers) = toml.get("providers").and_then(|v| v.as_table()) {
|
||||
let work_mins = providers
|
||||
.get("pomodoro_work_mins")
|
||||
.and_then(|v| v.as_integer())
|
||||
.map(|v| v as u32)
|
||||
.unwrap_or(DEFAULT_WORK_MINS);
|
||||
|
||||
let break_mins = providers
|
||||
.get("pomodoro_break_mins")
|
||||
.and_then(|v| v.as_integer())
|
||||
.map(|v| v as u32)
|
||||
.unwrap_or(DEFAULT_BREAK_MINS);
|
||||
|
||||
return Self { work_mins, break_mins };
|
||||
}
|
||||
}
|
||||
|
||||
// Default config
|
||||
Self {
|
||||
work_mins: DEFAULT_WORK_MINS,
|
||||
break_mins: DEFAULT_BREAK_MINS,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse config from a TOML table
|
||||
fn from_toml_table(table: &toml::Table) -> Self {
|
||||
let work_mins = table
|
||||
.get("work_mins")
|
||||
.and_then(|v| v.as_integer())
|
||||
.map(|v| v as u32)
|
||||
.unwrap_or(DEFAULT_WORK_MINS);
|
||||
|
||||
let break_mins = table
|
||||
.get("break_mins")
|
||||
.and_then(|v| v.as_integer())
|
||||
.map(|v| v as u32)
|
||||
.unwrap_or(DEFAULT_BREAK_MINS);
|
||||
|
||||
Self { work_mins, break_mins }
|
||||
}
|
||||
}
|
||||
|
||||
/// Timer phase
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
|
||||
enum PomodoroPhase {
|
||||
#[default]
|
||||
Idle,
|
||||
Working,
|
||||
WorkPaused,
|
||||
Break,
|
||||
BreakPaused,
|
||||
}
|
||||
|
||||
/// Persistent state (saved to disk)
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
struct PomodoroState {
|
||||
phase: PomodoroPhase,
|
||||
remaining_secs: u32,
|
||||
sessions: u32,
|
||||
last_update: u64,
|
||||
}
|
||||
|
||||
/// Pomodoro provider state
|
||||
struct PomodoroProviderState {
|
||||
items: Vec<PluginItem>,
|
||||
state: PomodoroState,
|
||||
work_mins: u32,
|
||||
break_mins: u32,
|
||||
}
|
||||
|
||||
impl PomodoroProviderState {
|
||||
fn new() -> Self {
|
||||
let config = PomodoroConfig::load();
|
||||
|
||||
let state = Self::load_state().unwrap_or_else(|| PomodoroState {
|
||||
phase: PomodoroPhase::Idle,
|
||||
remaining_secs: config.work_mins * 60,
|
||||
sessions: 0,
|
||||
last_update: Self::now_secs(),
|
||||
});
|
||||
|
||||
let mut provider = Self {
|
||||
items: Vec::new(),
|
||||
state,
|
||||
work_mins: config.work_mins,
|
||||
break_mins: config.break_mins,
|
||||
};
|
||||
|
||||
provider.update_elapsed_time();
|
||||
provider.generate_items();
|
||||
provider
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn data_dir() -> Option<PathBuf> {
|
||||
dirs::data_dir().map(|d| d.join("owlry"))
|
||||
}
|
||||
|
||||
fn load_state() -> Option<PomodoroState> {
|
||||
let path = Self::data_dir()?.join("pomodoro.json");
|
||||
let content = fs::read_to_string(&path).ok()?;
|
||||
serde_json::from_str(&content).ok()
|
||||
}
|
||||
|
||||
fn save_state(&self) {
|
||||
if let Some(data_dir) = Self::data_dir() {
|
||||
let path = data_dir.join("pomodoro.json");
|
||||
if fs::create_dir_all(&data_dir).is_err() {
|
||||
return;
|
||||
}
|
||||
let mut state = self.state.clone();
|
||||
state.last_update = Self::now_secs();
|
||||
if let Ok(json) = serde_json::to_string_pretty(&state) {
|
||||
let _ = fs::write(&path, json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_elapsed_time(&mut self) {
|
||||
let now = Self::now_secs();
|
||||
let elapsed = now.saturating_sub(self.state.last_update);
|
||||
|
||||
match self.state.phase {
|
||||
PomodoroPhase::Working | PomodoroPhase::Break => {
|
||||
if elapsed >= self.state.remaining_secs as u64 {
|
||||
self.complete_phase();
|
||||
} else {
|
||||
self.state.remaining_secs -= elapsed as u32;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.state.last_update = now;
|
||||
}
|
||||
|
||||
fn complete_phase(&mut self) {
|
||||
match self.state.phase {
|
||||
PomodoroPhase::Working => {
|
||||
self.state.sessions += 1;
|
||||
self.state.phase = PomodoroPhase::Break;
|
||||
self.state.remaining_secs = self.break_mins * 60;
|
||||
notify_with_urgency(
|
||||
"Pomodoro Complete!",
|
||||
&format!(
|
||||
"Great work! Session {} complete. Time for a {}-minute break.",
|
||||
self.state.sessions, self.break_mins
|
||||
),
|
||||
"alarm",
|
||||
NotifyUrgency::Normal,
|
||||
);
|
||||
}
|
||||
PomodoroPhase::Break => {
|
||||
self.state.phase = PomodoroPhase::Idle;
|
||||
self.state.remaining_secs = self.work_mins * 60;
|
||||
notify_with_urgency(
|
||||
"Break Complete",
|
||||
"Break time's over! Ready for another work session?",
|
||||
"alarm",
|
||||
NotifyUrgency::Normal,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.save_state();
|
||||
}
|
||||
|
||||
fn refresh(&mut self) {
|
||||
self.update_elapsed_time();
|
||||
self.generate_items();
|
||||
}
|
||||
|
||||
fn handle_action(&mut self, action: &str) {
|
||||
match action {
|
||||
"start" => {
|
||||
self.state.phase = PomodoroPhase::Working;
|
||||
self.state.remaining_secs = self.work_mins * 60;
|
||||
self.state.last_update = Self::now_secs();
|
||||
}
|
||||
"pause" => match self.state.phase {
|
||||
PomodoroPhase::Working => self.state.phase = PomodoroPhase::WorkPaused,
|
||||
PomodoroPhase::Break => self.state.phase = PomodoroPhase::BreakPaused,
|
||||
_ => {}
|
||||
},
|
||||
"resume" => {
|
||||
self.state.last_update = Self::now_secs();
|
||||
match self.state.phase {
|
||||
PomodoroPhase::WorkPaused => self.state.phase = PomodoroPhase::Working,
|
||||
PomodoroPhase::BreakPaused => self.state.phase = PomodoroPhase::Break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
"skip" => self.complete_phase(),
|
||||
"reset" => {
|
||||
self.state.phase = PomodoroPhase::Idle;
|
||||
self.state.remaining_secs = self.work_mins * 60;
|
||||
self.state.sessions = 0;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.save_state();
|
||||
self.generate_items();
|
||||
}
|
||||
|
||||
fn format_time(secs: u32) -> String {
|
||||
let mins = secs / 60;
|
||||
let secs = secs % 60;
|
||||
format!("{:02}:{:02}", mins, secs)
|
||||
}
|
||||
|
||||
/// Generate single main item with submenu for controls
|
||||
fn generate_items(&mut self) {
|
||||
self.items.clear();
|
||||
|
||||
let (phase_name, _is_running) = match self.state.phase {
|
||||
PomodoroPhase::Idle => ("Ready", false),
|
||||
PomodoroPhase::Working => ("Work", true),
|
||||
PomodoroPhase::WorkPaused => ("Paused", false),
|
||||
PomodoroPhase::Break => ("Break", true),
|
||||
PomodoroPhase::BreakPaused => ("Paused", false),
|
||||
};
|
||||
|
||||
let time_str = Self::format_time(self.state.remaining_secs);
|
||||
let name = format!("{}: {}", phase_name, time_str);
|
||||
|
||||
let description = if self.state.sessions > 0 {
|
||||
format!(
|
||||
"Sessions: {} | {}min work / {}min break",
|
||||
self.state.sessions, self.work_mins, self.break_mins
|
||||
)
|
||||
} else {
|
||||
format!("{}min work / {}min break", self.work_mins, self.break_mins)
|
||||
};
|
||||
|
||||
// Single item that opens submenu with controls
|
||||
self.items.push(
|
||||
PluginItem::new("pomo-timer", name, "SUBMENU:pomodoro:controls")
|
||||
.with_description(description)
|
||||
.with_icon("/org/owlry/launcher/icons/pomodoro/tomato.svg")
|
||||
.with_keywords(vec![
|
||||
"pomodoro".to_string(),
|
||||
"widget".to_string(),
|
||||
"timer".to_string(),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/// Generate submenu items for controls
|
||||
fn generate_submenu_items(&self) -> Vec<PluginItem> {
|
||||
let mut items = Vec::new();
|
||||
let is_running = matches!(
|
||||
self.state.phase,
|
||||
PomodoroPhase::Working | PomodoroPhase::Break
|
||||
);
|
||||
|
||||
// Primary control: Start/Pause/Resume
|
||||
if is_running {
|
||||
items.push(
|
||||
PluginItem::new("pomo-pause", "Pause", "POMODORO:pause")
|
||||
.with_description("Pause the timer")
|
||||
.with_icon("media-playback-pause"),
|
||||
);
|
||||
} else {
|
||||
match self.state.phase {
|
||||
PomodoroPhase::Idle => {
|
||||
items.push(
|
||||
PluginItem::new("pomo-start", "Start Work", "POMODORO:start")
|
||||
.with_description("Start a new work session")
|
||||
.with_icon("media-playback-start"),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
items.push(
|
||||
PluginItem::new("pomo-resume", "Resume", "POMODORO:resume")
|
||||
.with_description("Resume the timer")
|
||||
.with_icon("media-playback-start"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skip (only when not idle)
|
||||
if self.state.phase != PomodoroPhase::Idle {
|
||||
items.push(
|
||||
PluginItem::new("pomo-skip", "Skip", "POMODORO:skip")
|
||||
.with_description("Skip to next phase")
|
||||
.with_icon("media-skip-forward"),
|
||||
);
|
||||
}
|
||||
|
||||
// Reset
|
||||
items.push(
|
||||
PluginItem::new("pomo-reset", "Reset", "POMODORO:reset")
|
||||
.with_description("Reset timer and sessions")
|
||||
.with_icon("view-refresh"),
|
||||
);
|
||||
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Interface Implementation
|
||||
// ============================================================================
|
||||
|
||||
extern "C" fn plugin_info() -> PluginInfo {
|
||||
PluginInfo {
|
||||
id: RString::from(PLUGIN_ID),
|
||||
name: RString::from(PLUGIN_NAME),
|
||||
version: RString::from(PLUGIN_VERSION),
|
||||
description: RString::from(PLUGIN_DESCRIPTION),
|
||||
api_version: API_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
|
||||
vec![ProviderInfo {
|
||||
id: RString::from(PROVIDER_ID),
|
||||
name: RString::from(PROVIDER_NAME),
|
||||
prefix: ROption::RNone,
|
||||
icon: RString::from(PROVIDER_ICON),
|
||||
provider_type: ProviderKind::Static,
|
||||
type_id: RString::from(PROVIDER_TYPE_ID),
|
||||
position: ProviderPosition::Widget,
|
||||
priority: 11500, // Widget: pomodoro timer
|
||||
}]
|
||||
.into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
|
||||
let state = Box::new(PomodoroProviderState::new());
|
||||
ProviderHandle::from_box(state)
|
||||
}
|
||||
|
||||
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
|
||||
if handle.ptr.is_null() {
|
||||
return RVec::new();
|
||||
}
|
||||
|
||||
let state = unsafe { &mut *(handle.ptr as *mut PomodoroProviderState) };
|
||||
state.refresh();
|
||||
state.items.clone().into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_query(handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem> {
|
||||
if handle.ptr.is_null() {
|
||||
return RVec::new();
|
||||
}
|
||||
|
||||
let query_str = query.as_str();
|
||||
let state = unsafe { &mut *(handle.ptr as *mut PomodoroProviderState) };
|
||||
|
||||
// Handle submenu request
|
||||
if query_str == "?SUBMENU:controls" {
|
||||
return state.generate_submenu_items().into();
|
||||
}
|
||||
|
||||
// Handle action commands
|
||||
if let Some(action) = query_str.strip_prefix("!POMODORO:") {
|
||||
state.handle_action(action);
|
||||
}
|
||||
|
||||
RVec::new()
|
||||
}
|
||||
|
||||
extern "C" fn provider_drop(handle: ProviderHandle) {
|
||||
if !handle.ptr.is_null() {
|
||||
let state = unsafe { &*(handle.ptr as *const PomodoroProviderState) };
|
||||
state.save_state();
|
||||
unsafe {
|
||||
handle.drop_as::<PomodoroProviderState>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
owlry_plugin! {
|
||||
info: plugin_info,
|
||||
providers: plugin_providers,
|
||||
init: provider_init,
|
||||
refresh: provider_refresh,
|
||||
query: provider_query,
|
||||
drop: provider_drop,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_format_time() {
|
||||
assert_eq!(PomodoroProviderState::format_time(0), "00:00");
|
||||
assert_eq!(PomodoroProviderState::format_time(60), "01:00");
|
||||
assert_eq!(PomodoroProviderState::format_time(90), "01:30");
|
||||
assert_eq!(PomodoroProviderState::format_time(1500), "25:00");
|
||||
assert_eq!(PomodoroProviderState::format_time(3599), "59:59");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_phase() {
|
||||
let phase: PomodoroPhase = Default::default();
|
||||
assert_eq!(phase, PomodoroPhase::Idle);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
[package]
|
||||
name = "owlry-plugin-scripts"
|
||||
version = "0.4.10"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Scripts plugin for owlry - run user scripts from ~/.local/share/owlry/scripts/"
|
||||
keywords = ["owlry", "plugin", "scripts"]
|
||||
categories = ["os"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
|
||||
# For finding ~/.local/share/owlry/scripts
|
||||
dirs = "5.0"
|
||||
@@ -1,290 +0,0 @@
|
||||
//! Scripts Plugin for Owlry
|
||||
//!
|
||||
//! A static provider that scans `~/.local/share/owlry/scripts/` for executable
|
||||
//! scripts and provides them as launch items.
|
||||
//!
|
||||
//! Scripts can include a description by adding a comment after the shebang:
|
||||
//! ```bash
|
||||
//! #!/bin/bash
|
||||
//! # This is my script description
|
||||
//! echo "Hello"
|
||||
//! ```
|
||||
|
||||
use abi_stable::std_types::{ROption, RStr, RString, RVec};
|
||||
use owlry_plugin_api::{
|
||||
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
|
||||
ProviderPosition, API_VERSION,
|
||||
};
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
// Plugin metadata
|
||||
const PLUGIN_ID: &str = "scripts";
|
||||
const PLUGIN_NAME: &str = "Scripts";
|
||||
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const PLUGIN_DESCRIPTION: &str = "Run user scripts from ~/.local/share/owlry/scripts/";
|
||||
|
||||
// Provider metadata
|
||||
const PROVIDER_ID: &str = "scripts";
|
||||
const PROVIDER_NAME: &str = "Scripts";
|
||||
const PROVIDER_PREFIX: &str = ":script";
|
||||
const PROVIDER_ICON: &str = "utilities-terminal";
|
||||
const PROVIDER_TYPE_ID: &str = "scripts";
|
||||
|
||||
/// Scripts provider state - holds cached items
|
||||
struct ScriptsState {
|
||||
items: Vec<PluginItem>,
|
||||
}
|
||||
|
||||
impl ScriptsState {
|
||||
fn new() -> Self {
|
||||
Self { items: Vec::new() }
|
||||
}
|
||||
|
||||
fn scripts_dir() -> Option<PathBuf> {
|
||||
dirs::data_dir().map(|d| d.join("owlry").join("scripts"))
|
||||
}
|
||||
|
||||
fn load_scripts(&mut self) {
|
||||
self.items.clear();
|
||||
|
||||
let scripts_dir = match Self::scripts_dir() {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
if !scripts_dir.exists() {
|
||||
// Create the directory for the user
|
||||
let _ = fs::create_dir_all(&scripts_dir);
|
||||
return;
|
||||
}
|
||||
|
||||
let entries = match fs::read_dir(&scripts_dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
|
||||
// Skip directories
|
||||
if path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if executable
|
||||
let metadata = match path.metadata() {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let is_executable = metadata.permissions().mode() & 0o111 != 0;
|
||||
if !is_executable {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get script name without extension
|
||||
let filename = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let name = path
|
||||
.file_stem()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or(filename.clone());
|
||||
|
||||
// Try to read description from first line comment
|
||||
let description = Self::read_script_description(&path);
|
||||
|
||||
// Determine icon based on extension or shebang
|
||||
let icon = Self::determine_icon(&path);
|
||||
|
||||
let mut item = PluginItem::new(
|
||||
format!("script:{}", filename),
|
||||
format!("Script: {}", name),
|
||||
path.to_string_lossy().to_string(),
|
||||
)
|
||||
.with_icon(icon)
|
||||
.with_keywords(vec!["script".to_string()]);
|
||||
|
||||
if let Some(desc) = description {
|
||||
item = item.with_description(desc);
|
||||
}
|
||||
|
||||
self.items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
fn read_script_description(path: &PathBuf) -> Option<String> {
|
||||
let content = fs::read_to_string(path).ok()?;
|
||||
let mut lines = content.lines();
|
||||
|
||||
// Skip shebang if present
|
||||
let first_line = lines.next()?;
|
||||
let check_line = if first_line.starts_with("#!") {
|
||||
lines.next()?
|
||||
} else {
|
||||
first_line
|
||||
};
|
||||
|
||||
// Look for a comment description
|
||||
if let Some(desc) = check_line.strip_prefix("# ") {
|
||||
Some(desc.trim().to_string())
|
||||
} else { check_line.strip_prefix("// ").map(|desc| desc.trim().to_string()) }
|
||||
}
|
||||
|
||||
fn determine_icon(path: &PathBuf) -> String {
|
||||
// Check extension first
|
||||
if let Some(ext) = path.extension() {
|
||||
match ext.to_string_lossy().as_ref() {
|
||||
"sh" | "bash" | "zsh" => return "utilities-terminal".to_string(),
|
||||
"py" | "python" => return "text-x-python".to_string(),
|
||||
"js" | "ts" => return "text-x-javascript".to_string(),
|
||||
"rb" => return "text-x-ruby".to_string(),
|
||||
"pl" => return "text-x-perl".to_string(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Check shebang
|
||||
if let Ok(content) = fs::read_to_string(path)
|
||||
&& let Some(first_line) = content.lines().next() {
|
||||
if first_line.contains("bash") || first_line.contains("sh") {
|
||||
return "utilities-terminal".to_string();
|
||||
} else if first_line.contains("python") {
|
||||
return "text-x-python".to_string();
|
||||
} else if first_line.contains("node") {
|
||||
return "text-x-javascript".to_string();
|
||||
} else if first_line.contains("ruby") {
|
||||
return "text-x-ruby".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
"application-x-executable".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Interface Implementation
|
||||
// ============================================================================
|
||||
|
||||
extern "C" fn plugin_info() -> PluginInfo {
|
||||
PluginInfo {
|
||||
id: RString::from(PLUGIN_ID),
|
||||
name: RString::from(PLUGIN_NAME),
|
||||
version: RString::from(PLUGIN_VERSION),
|
||||
description: RString::from(PLUGIN_DESCRIPTION),
|
||||
api_version: API_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
|
||||
vec![ProviderInfo {
|
||||
id: RString::from(PROVIDER_ID),
|
||||
name: RString::from(PROVIDER_NAME),
|
||||
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
|
||||
icon: RString::from(PROVIDER_ICON),
|
||||
provider_type: ProviderKind::Static,
|
||||
type_id: RString::from(PROVIDER_TYPE_ID),
|
||||
position: ProviderPosition::Normal,
|
||||
priority: 0, // Static: use frecency ordering
|
||||
}]
|
||||
.into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
|
||||
let state = Box::new(ScriptsState::new());
|
||||
ProviderHandle::from_box(state)
|
||||
}
|
||||
|
||||
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
|
||||
if handle.ptr.is_null() {
|
||||
return RVec::new();
|
||||
}
|
||||
|
||||
// SAFETY: We created this handle from Box<ScriptsState>
|
||||
let state = unsafe { &mut *(handle.ptr as *mut ScriptsState) };
|
||||
|
||||
// Load scripts
|
||||
state.load_scripts();
|
||||
|
||||
// Return items
|
||||
state.items.to_vec().into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec<PluginItem> {
|
||||
// Static provider - query is handled by the core using cached items
|
||||
RVec::new()
|
||||
}
|
||||
|
||||
extern "C" fn provider_drop(handle: ProviderHandle) {
|
||||
if !handle.ptr.is_null() {
|
||||
// SAFETY: We created this handle from Box<ScriptsState>
|
||||
unsafe {
|
||||
handle.drop_as::<ScriptsState>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the plugin vtable
|
||||
owlry_plugin! {
|
||||
info: plugin_info,
|
||||
providers: plugin_providers,
|
||||
init: provider_init,
|
||||
refresh: provider_refresh,
|
||||
query: provider_query,
|
||||
drop: provider_drop,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_scripts_state_new() {
|
||||
let state = ScriptsState::new();
|
||||
assert!(state.items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_determine_icon_sh() {
|
||||
let path = PathBuf::from("/test/script.sh");
|
||||
let icon = ScriptsState::determine_icon(&path);
|
||||
assert_eq!(icon, "utilities-terminal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_determine_icon_python() {
|
||||
let path = PathBuf::from("/test/script.py");
|
||||
let icon = ScriptsState::determine_icon(&path);
|
||||
assert_eq!(icon, "text-x-python");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_determine_icon_js() {
|
||||
let path = PathBuf::from("/test/script.js");
|
||||
let icon = ScriptsState::determine_icon(&path);
|
||||
assert_eq!(icon, "text-x-javascript");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_determine_icon_unknown() {
|
||||
let path = PathBuf::from("/test/script.xyz");
|
||||
let icon = ScriptsState::determine_icon(&path);
|
||||
assert_eq!(icon, "application-x-executable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scripts_dir() {
|
||||
// Should return Some path
|
||||
let dir = ScriptsState::scripts_dir();
|
||||
assert!(dir.is_some());
|
||||
assert!(dir.unwrap().ends_with("owlry/scripts"));
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
[package]
|
||||
name = "owlry-plugin-ssh"
|
||||
version = "0.4.10"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "SSH plugin for owlry - quick connect to SSH hosts from ~/.ssh/config"
|
||||
keywords = ["owlry", "plugin", "ssh"]
|
||||
categories = ["network-programming"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
|
||||
# For finding ~/.ssh/config
|
||||
dirs = "5.0"
|
||||
@@ -1,328 +0,0 @@
|
||||
//! SSH Plugin for Owlry
|
||||
//!
|
||||
//! A static provider that parses ~/.ssh/config and provides quick-connect
|
||||
//! entries for SSH hosts.
|
||||
//!
|
||||
//! Examples:
|
||||
//! - `SSH: myserver` → Connect to myserver
|
||||
//! - `SSH: work-box` → Connect to work-box with configured user/port
|
||||
|
||||
use abi_stable::std_types::{ROption, RStr, RString, RVec};
|
||||
use owlry_plugin_api::{
|
||||
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
|
||||
ProviderPosition, API_VERSION,
|
||||
};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
// Plugin metadata
|
||||
const PLUGIN_ID: &str = "ssh";
|
||||
const PLUGIN_NAME: &str = "SSH";
|
||||
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const PLUGIN_DESCRIPTION: &str = "Quick connect to SSH hosts from ~/.ssh/config";
|
||||
|
||||
// Provider metadata
|
||||
const PROVIDER_ID: &str = "ssh";
|
||||
const PROVIDER_NAME: &str = "SSH";
|
||||
const PROVIDER_PREFIX: &str = ":ssh";
|
||||
const PROVIDER_ICON: &str = "utilities-terminal";
|
||||
const PROVIDER_TYPE_ID: &str = "ssh";
|
||||
|
||||
// Default terminal command (TODO: make configurable via plugin config)
|
||||
const DEFAULT_TERMINAL: &str = "kitty";
|
||||
|
||||
/// SSH provider state - holds cached items
|
||||
struct SshState {
|
||||
items: Vec<PluginItem>,
|
||||
terminal_command: String,
|
||||
}
|
||||
|
||||
impl SshState {
|
||||
fn new() -> Self {
|
||||
// Try to detect terminal from environment, fall back to default
|
||||
let terminal = std::env::var("TERMINAL")
|
||||
.unwrap_or_else(|_| DEFAULT_TERMINAL.to_string());
|
||||
|
||||
Self {
|
||||
items: Vec::new(),
|
||||
terminal_command: terminal,
|
||||
}
|
||||
}
|
||||
|
||||
fn ssh_config_path() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|h| h.join(".ssh").join("config"))
|
||||
}
|
||||
|
||||
fn parse_ssh_config(&mut self) {
|
||||
self.items.clear();
|
||||
|
||||
let config_path = match Self::ssh_config_path() {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
if !config_path.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let content = match fs::read_to_string(&config_path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let mut current_host: Option<String> = None;
|
||||
let mut current_hostname: Option<String> = None;
|
||||
let mut current_user: Option<String> = None;
|
||||
let mut current_port: Option<String> = None;
|
||||
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
|
||||
// Skip comments and empty lines
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Split on whitespace or '='
|
||||
let parts: Vec<&str> = line
|
||||
.splitn(2, |c: char| c.is_whitespace() || c == '=')
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
if parts.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let key = parts[0].to_lowercase();
|
||||
let value = parts[1];
|
||||
|
||||
match key.as_str() {
|
||||
"host" => {
|
||||
// Save previous host if exists
|
||||
if let Some(host) = current_host.take() {
|
||||
self.add_host_item(
|
||||
&host,
|
||||
current_hostname.take(),
|
||||
current_user.take(),
|
||||
current_port.take(),
|
||||
);
|
||||
}
|
||||
|
||||
// Skip wildcards and patterns
|
||||
if !value.contains('*') && !value.contains('?') && value != "*" {
|
||||
current_host = Some(value.to_string());
|
||||
}
|
||||
current_hostname = None;
|
||||
current_user = None;
|
||||
current_port = None;
|
||||
}
|
||||
"hostname" => {
|
||||
current_hostname = Some(value.to_string());
|
||||
}
|
||||
"user" => {
|
||||
current_user = Some(value.to_string());
|
||||
}
|
||||
"port" => {
|
||||
current_port = Some(value.to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last host
|
||||
if let Some(host) = current_host.take() {
|
||||
self.add_host_item(&host, current_hostname, current_user, current_port);
|
||||
}
|
||||
}
|
||||
|
||||
fn add_host_item(
|
||||
&mut self,
|
||||
host: &str,
|
||||
hostname: Option<String>,
|
||||
user: Option<String>,
|
||||
port: Option<String>,
|
||||
) {
|
||||
// Build description
|
||||
let mut desc_parts = Vec::new();
|
||||
if let Some(ref h) = hostname {
|
||||
desc_parts.push(h.clone());
|
||||
}
|
||||
if let Some(ref u) = user {
|
||||
desc_parts.push(format!("user: {}", u));
|
||||
}
|
||||
if let Some(ref p) = port {
|
||||
desc_parts.push(format!("port: {}", p));
|
||||
}
|
||||
|
||||
let description = if desc_parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(desc_parts.join(", "))
|
||||
};
|
||||
|
||||
// Build SSH command - just use the host alias, SSH will resolve the rest
|
||||
let ssh_command = format!("ssh {}", host);
|
||||
|
||||
// Wrap in terminal
|
||||
let command = format!("{} -e {}", self.terminal_command, ssh_command);
|
||||
|
||||
let mut item = PluginItem::new(
|
||||
format!("ssh:{}", host),
|
||||
format!("SSH: {}", host),
|
||||
command,
|
||||
)
|
||||
.with_icon(PROVIDER_ICON)
|
||||
.with_keywords(vec!["ssh".to_string(), "remote".to_string()]);
|
||||
|
||||
if let Some(desc) = description {
|
||||
item = item.with_description(desc);
|
||||
}
|
||||
|
||||
self.items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Interface Implementation
|
||||
// ============================================================================
|
||||
|
||||
extern "C" fn plugin_info() -> PluginInfo {
|
||||
PluginInfo {
|
||||
id: RString::from(PLUGIN_ID),
|
||||
name: RString::from(PLUGIN_NAME),
|
||||
version: RString::from(PLUGIN_VERSION),
|
||||
description: RString::from(PLUGIN_DESCRIPTION),
|
||||
api_version: API_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
|
||||
vec![ProviderInfo {
|
||||
id: RString::from(PROVIDER_ID),
|
||||
name: RString::from(PROVIDER_NAME),
|
||||
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
|
||||
icon: RString::from(PROVIDER_ICON),
|
||||
provider_type: ProviderKind::Static,
|
||||
type_id: RString::from(PROVIDER_TYPE_ID),
|
||||
position: ProviderPosition::Normal,
|
||||
priority: 0, // Static: use frecency ordering
|
||||
}]
|
||||
.into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
|
||||
let state = Box::new(SshState::new());
|
||||
ProviderHandle::from_box(state)
|
||||
}
|
||||
|
||||
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
|
||||
if handle.ptr.is_null() {
|
||||
return RVec::new();
|
||||
}
|
||||
|
||||
// SAFETY: We created this handle from Box<SshState>
|
||||
let state = unsafe { &mut *(handle.ptr as *mut SshState) };
|
||||
|
||||
// Parse SSH config
|
||||
state.parse_ssh_config();
|
||||
|
||||
// Return items
|
||||
state.items.to_vec().into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec<PluginItem> {
|
||||
// Static provider - query is handled by the core using cached items
|
||||
RVec::new()
|
||||
}
|
||||
|
||||
extern "C" fn provider_drop(handle: ProviderHandle) {
|
||||
if !handle.ptr.is_null() {
|
||||
// SAFETY: We created this handle from Box<SshState>
|
||||
unsafe {
|
||||
handle.drop_as::<SshState>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the plugin vtable
|
||||
owlry_plugin! {
|
||||
info: plugin_info,
|
||||
providers: plugin_providers,
|
||||
init: provider_init,
|
||||
refresh: provider_refresh,
|
||||
query: provider_query,
|
||||
drop: provider_drop,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_ssh_state_new() {
|
||||
let state = SshState::new();
|
||||
assert!(state.items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_simple_config() {
|
||||
let mut state = SshState::new();
|
||||
|
||||
// We can't easily test the full flow without mocking file paths,
|
||||
// but we can test the add_host_item method
|
||||
state.add_host_item(
|
||||
"myserver",
|
||||
Some("192.168.1.100".to_string()),
|
||||
Some("admin".to_string()),
|
||||
Some("2222".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(state.items.len(), 1);
|
||||
assert_eq!(state.items[0].name.as_str(), "SSH: myserver");
|
||||
assert!(state.items[0].command.as_str().contains("ssh myserver"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_host_without_details() {
|
||||
let mut state = SshState::new();
|
||||
state.add_host_item("simple-host", None, None, None);
|
||||
|
||||
assert_eq!(state.items.len(), 1);
|
||||
assert_eq!(state.items[0].name.as_str(), "SSH: simple-host");
|
||||
assert!(state.items[0].description.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_host_with_partial_details() {
|
||||
let mut state = SshState::new();
|
||||
state.add_host_item("partial", Some("example.com".to_string()), None, None);
|
||||
|
||||
assert_eq!(state.items.len(), 1);
|
||||
let desc = state.items[0].description.as_ref().unwrap();
|
||||
assert_eq!(desc.as_str(), "example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_items_have_icons() {
|
||||
let mut state = SshState::new();
|
||||
state.add_host_item("test", None, None, None);
|
||||
|
||||
assert!(state.items[0].icon.is_some());
|
||||
assert_eq!(state.items[0].icon.as_ref().unwrap().as_str(), PROVIDER_ICON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_items_have_keywords() {
|
||||
let mut state = SshState::new();
|
||||
state.add_host_item("test", None, None, None);
|
||||
|
||||
assert!(!state.items[0].keywords.is_empty());
|
||||
let keywords: Vec<&str> = state.items[0].keywords.iter().map(|s| s.as_str()).collect();
|
||||
assert!(keywords.contains(&"ssh"));
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
[package]
|
||||
name = "owlry-plugin-system"
|
||||
version = "0.4.10"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "System plugin for owlry - power and session management commands"
|
||||
keywords = ["owlry", "plugin", "system", "power"]
|
||||
categories = ["os"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
@@ -1,254 +0,0 @@
|
||||
//! System Plugin for Owlry
|
||||
//!
|
||||
//! A static provider that provides system power and session management commands.
|
||||
//!
|
||||
//! Commands:
|
||||
//! - Shutdown - Power off the system
|
||||
//! - Reboot - Restart the system
|
||||
//! - Reboot into BIOS - Restart into UEFI/BIOS setup
|
||||
//! - Suspend - Suspend to RAM
|
||||
//! - Hibernate - Suspend to disk
|
||||
//! - Lock Screen - Lock the session
|
||||
//! - Log Out - End the current session
|
||||
|
||||
use abi_stable::std_types::{ROption, RStr, RString, RVec};
|
||||
use owlry_plugin_api::{
|
||||
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
|
||||
ProviderPosition, API_VERSION,
|
||||
};
|
||||
|
||||
// Plugin metadata
|
||||
const PLUGIN_ID: &str = "system";
|
||||
const PLUGIN_NAME: &str = "System";
|
||||
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const PLUGIN_DESCRIPTION: &str = "Power and session management commands";
|
||||
|
||||
// Provider metadata
|
||||
const PROVIDER_ID: &str = "system";
|
||||
const PROVIDER_NAME: &str = "System";
|
||||
const PROVIDER_PREFIX: &str = ":sys";
|
||||
const PROVIDER_ICON: &str = "system-shutdown";
|
||||
const PROVIDER_TYPE_ID: &str = "system";
|
||||
|
||||
/// System provider state - holds cached items
|
||||
struct SystemState {
|
||||
items: Vec<PluginItem>,
|
||||
}
|
||||
|
||||
impl SystemState {
|
||||
fn new() -> Self {
|
||||
Self { items: Vec::new() }
|
||||
}
|
||||
|
||||
fn load_commands(&mut self) {
|
||||
self.items.clear();
|
||||
|
||||
// Define system commands
|
||||
// Format: (id, name, description, icon, command)
|
||||
let commands: &[(&str, &str, &str, &str, &str)] = &[
|
||||
(
|
||||
"system:shutdown",
|
||||
"Shutdown",
|
||||
"Power off the system",
|
||||
"system-shutdown",
|
||||
"systemctl poweroff",
|
||||
),
|
||||
(
|
||||
"system:reboot",
|
||||
"Reboot",
|
||||
"Restart the system",
|
||||
"system-reboot",
|
||||
"systemctl reboot",
|
||||
),
|
||||
(
|
||||
"system:reboot-bios",
|
||||
"Reboot into BIOS",
|
||||
"Restart into UEFI/BIOS setup",
|
||||
"system-reboot",
|
||||
"systemctl reboot --firmware-setup",
|
||||
),
|
||||
(
|
||||
"system:suspend",
|
||||
"Suspend",
|
||||
"Suspend to RAM",
|
||||
"system-suspend",
|
||||
"systemctl suspend",
|
||||
),
|
||||
(
|
||||
"system:hibernate",
|
||||
"Hibernate",
|
||||
"Suspend to disk",
|
||||
"system-suspend-hibernate",
|
||||
"systemctl hibernate",
|
||||
),
|
||||
(
|
||||
"system:lock",
|
||||
"Lock Screen",
|
||||
"Lock the session",
|
||||
"system-lock-screen",
|
||||
"loginctl lock-session",
|
||||
),
|
||||
(
|
||||
"system:logout",
|
||||
"Log Out",
|
||||
"End the current session",
|
||||
"system-log-out",
|
||||
"loginctl terminate-session self",
|
||||
),
|
||||
];
|
||||
|
||||
for (id, name, description, icon, command) in commands {
|
||||
self.items.push(
|
||||
PluginItem::new(*id, *name, *command)
|
||||
.with_description(*description)
|
||||
.with_icon(*icon)
|
||||
.with_keywords(vec!["power".to_string(), "system".to_string()]),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Interface Implementation
|
||||
// ============================================================================
|
||||
|
||||
extern "C" fn plugin_info() -> PluginInfo {
|
||||
PluginInfo {
|
||||
id: RString::from(PLUGIN_ID),
|
||||
name: RString::from(PLUGIN_NAME),
|
||||
version: RString::from(PLUGIN_VERSION),
|
||||
description: RString::from(PLUGIN_DESCRIPTION),
|
||||
api_version: API_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
|
||||
vec![ProviderInfo {
|
||||
id: RString::from(PROVIDER_ID),
|
||||
name: RString::from(PROVIDER_NAME),
|
||||
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
|
||||
icon: RString::from(PROVIDER_ICON),
|
||||
provider_type: ProviderKind::Static,
|
||||
type_id: RString::from(PROVIDER_TYPE_ID),
|
||||
position: ProviderPosition::Normal,
|
||||
priority: 0, // Static: use frecency ordering
|
||||
}]
|
||||
.into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
|
||||
let state = Box::new(SystemState::new());
|
||||
ProviderHandle::from_box(state)
|
||||
}
|
||||
|
||||
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
|
||||
if handle.ptr.is_null() {
|
||||
return RVec::new();
|
||||
}
|
||||
|
||||
// SAFETY: We created this handle from Box<SystemState>
|
||||
let state = unsafe { &mut *(handle.ptr as *mut SystemState) };
|
||||
|
||||
// Load/reload commands
|
||||
state.load_commands();
|
||||
|
||||
// Return items
|
||||
state.items.to_vec().into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec<PluginItem> {
|
||||
// Static provider - query is handled by the core using cached items
|
||||
RVec::new()
|
||||
}
|
||||
|
||||
extern "C" fn provider_drop(handle: ProviderHandle) {
|
||||
if !handle.ptr.is_null() {
|
||||
// SAFETY: We created this handle from Box<SystemState>
|
||||
unsafe {
|
||||
handle.drop_as::<SystemState>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the plugin vtable
|
||||
owlry_plugin! {
|
||||
info: plugin_info,
|
||||
providers: plugin_providers,
|
||||
init: provider_init,
|
||||
refresh: provider_refresh,
|
||||
query: provider_query,
|
||||
drop: provider_drop,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_system_state_new() {
|
||||
let state = SystemState::new();
|
||||
assert!(state.items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_commands_loaded() {
|
||||
let mut state = SystemState::new();
|
||||
state.load_commands();
|
||||
|
||||
assert!(state.items.len() >= 6);
|
||||
|
||||
// Check for specific commands
|
||||
let names: Vec<&str> = state.items.iter().map(|i| i.name.as_str()).collect();
|
||||
assert!(names.contains(&"Shutdown"));
|
||||
assert!(names.contains(&"Reboot"));
|
||||
assert!(names.contains(&"Suspend"));
|
||||
assert!(names.contains(&"Lock Screen"));
|
||||
assert!(names.contains(&"Log Out"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reboot_bios_command() {
|
||||
let mut state = SystemState::new();
|
||||
state.load_commands();
|
||||
|
||||
let bios_cmd = state
|
||||
.items
|
||||
.iter()
|
||||
.find(|i| i.name.as_str() == "Reboot into BIOS")
|
||||
.expect("Reboot into BIOS should exist");
|
||||
|
||||
assert_eq!(bios_cmd.command.as_str(), "systemctl reboot --firmware-setup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commands_have_icons() {
|
||||
let mut state = SystemState::new();
|
||||
state.load_commands();
|
||||
|
||||
for item in &state.items {
|
||||
assert!(
|
||||
item.icon.is_some(),
|
||||
"Item '{}' should have an icon",
|
||||
item.name.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commands_have_descriptions() {
|
||||
let mut state = SystemState::new();
|
||||
state.load_commands();
|
||||
|
||||
for item in &state.items {
|
||||
assert!(
|
||||
item.description.is_some(),
|
||||
"Item '{}' should have a description",
|
||||
item.name.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
[package]
|
||||
name = "owlry-plugin-systemd"
|
||||
version = "0.4.10"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "systemd user services plugin for owlry - list and control user-level systemd services"
|
||||
keywords = ["owlry", "plugin", "systemd", "services"]
|
||||
categories = ["os"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
@@ -1,457 +0,0 @@
|
||||
//! systemd User Services Plugin for Owlry
|
||||
//!
|
||||
//! Lists and controls systemd user-level services.
|
||||
//! Uses `systemctl --user` commands to interact with services.
|
||||
//!
|
||||
//! Each service item opens a submenu with actions like:
|
||||
//! - Start/Stop/Restart/Reload/Kill
|
||||
//! - Enable/Disable on startup
|
||||
//! - View status and journal logs
|
||||
|
||||
use abi_stable::std_types::{ROption, RStr, RString, RVec};
|
||||
use owlry_plugin_api::{
|
||||
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
|
||||
ProviderPosition, API_VERSION,
|
||||
};
|
||||
use std::process::Command;
|
||||
|
||||
// Plugin metadata
|
||||
const PLUGIN_ID: &str = "systemd";
|
||||
const PLUGIN_NAME: &str = "systemd Services";
|
||||
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const PLUGIN_DESCRIPTION: &str = "List and control systemd user services";
|
||||
|
||||
// Provider metadata
|
||||
const PROVIDER_ID: &str = "systemd";
|
||||
const PROVIDER_NAME: &str = "User Units";
|
||||
const PROVIDER_PREFIX: &str = ":uuctl";
|
||||
const PROVIDER_ICON: &str = "system-run";
|
||||
const PROVIDER_TYPE_ID: &str = "uuctl";
|
||||
|
||||
/// systemd provider state
|
||||
struct SystemdState {
|
||||
items: Vec<PluginItem>,
|
||||
}
|
||||
|
||||
impl SystemdState {
|
||||
fn new() -> Self {
|
||||
let mut state = Self { items: Vec::new() };
|
||||
state.refresh();
|
||||
state
|
||||
}
|
||||
|
||||
fn refresh(&mut self) {
|
||||
self.items.clear();
|
||||
|
||||
if !Self::systemctl_available() {
|
||||
return;
|
||||
}
|
||||
|
||||
// List all user services (both running and available)
|
||||
let output = match Command::new("systemctl")
|
||||
.args([
|
||||
"--user",
|
||||
"list-units",
|
||||
"--type=service",
|
||||
"--all",
|
||||
"--no-legend",
|
||||
"--no-pager",
|
||||
])
|
||||
.output()
|
||||
{
|
||||
Ok(o) if o.status.success() => o,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
self.items = Self::parse_systemctl_output(&stdout);
|
||||
|
||||
// Sort by name
|
||||
self.items.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str()));
|
||||
}
|
||||
|
||||
fn systemctl_available() -> bool {
|
||||
Command::new("systemctl")
|
||||
.args(["--user", "--version"])
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn parse_systemctl_output(output: &str) -> Vec<PluginItem> {
|
||||
let mut items = Vec::new();
|
||||
|
||||
for line in output.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse systemctl output - handle variable whitespace
|
||||
// Format: UNIT LOAD ACTIVE SUB DESCRIPTION...
|
||||
let mut parts = line.split_whitespace();
|
||||
|
||||
let unit_name = match parts.next() {
|
||||
Some(u) => u,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Skip if not a proper service name
|
||||
if !unit_name.ends_with(".service") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let _load_state = parts.next().unwrap_or("");
|
||||
let active_state = parts.next().unwrap_or("");
|
||||
let sub_state = parts.next().unwrap_or("");
|
||||
let description: String = parts.collect::<Vec<_>>().join(" ");
|
||||
|
||||
// Create a clean display name
|
||||
let display_name = unit_name
|
||||
.trim_end_matches(".service")
|
||||
.replace("app-", "")
|
||||
.replace("@autostart", "")
|
||||
.replace("\\x2d", "-");
|
||||
|
||||
let is_active = active_state == "active";
|
||||
let status_icon = if is_active { "●" } else { "○" };
|
||||
|
||||
let status_desc = if description.is_empty() {
|
||||
format!("{} {} ({})", status_icon, sub_state, active_state)
|
||||
} else {
|
||||
format!("{} {} ({})", status_icon, description, sub_state)
|
||||
};
|
||||
|
||||
// Store service info in the command field as encoded data
|
||||
// Format: SUBMENU:type_id:data where data is "unit_name:is_active"
|
||||
let submenu_data = format!("SUBMENU:uuctl:{}:{}", unit_name, is_active);
|
||||
|
||||
let icon = if is_active {
|
||||
"emblem-ok-symbolic"
|
||||
} else {
|
||||
"emblem-pause-symbolic"
|
||||
};
|
||||
|
||||
items.push(
|
||||
PluginItem::new(
|
||||
format!("systemd:service:{}", unit_name),
|
||||
display_name,
|
||||
submenu_data,
|
||||
)
|
||||
.with_description(status_desc)
|
||||
.with_icon(icon)
|
||||
.with_keywords(vec!["systemd".to_string(), "service".to_string()]),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Submenu Action Generation (exported for core to use)
|
||||
// ============================================================================
|
||||
|
||||
/// Generate submenu actions for a given service
|
||||
/// This function is called by the core when a service is selected
|
||||
pub fn actions_for_service(unit_name: &str, display_name: &str, is_active: bool) -> Vec<PluginItem> {
|
||||
let mut actions = Vec::new();
|
||||
|
||||
if is_active {
|
||||
actions.push(
|
||||
PluginItem::new(
|
||||
format!("systemd:restart:{}", unit_name),
|
||||
"↻ Restart",
|
||||
format!("systemctl --user restart {}", unit_name),
|
||||
)
|
||||
.with_description(format!("Restart {}", display_name))
|
||||
.with_icon("view-refresh")
|
||||
.with_keywords(vec!["systemd".to_string(), "service".to_string()]),
|
||||
);
|
||||
|
||||
actions.push(
|
||||
PluginItem::new(
|
||||
format!("systemd:stop:{}", unit_name),
|
||||
"■ Stop",
|
||||
format!("systemctl --user stop {}", unit_name),
|
||||
)
|
||||
.with_description(format!("Stop {}", display_name))
|
||||
.with_icon("process-stop")
|
||||
.with_keywords(vec!["systemd".to_string(), "service".to_string()]),
|
||||
);
|
||||
|
||||
actions.push(
|
||||
PluginItem::new(
|
||||
format!("systemd:reload:{}", unit_name),
|
||||
"⟳ Reload",
|
||||
format!("systemctl --user reload {}", unit_name),
|
||||
)
|
||||
.with_description(format!("Reload {} configuration", display_name))
|
||||
.with_icon("view-refresh")
|
||||
.with_keywords(vec!["systemd".to_string(), "service".to_string()]),
|
||||
);
|
||||
|
||||
actions.push(
|
||||
PluginItem::new(
|
||||
format!("systemd:kill:{}", unit_name),
|
||||
"✗ Kill",
|
||||
format!("systemctl --user kill {}", unit_name),
|
||||
)
|
||||
.with_description(format!("Force kill {}", display_name))
|
||||
.with_icon("edit-delete")
|
||||
.with_keywords(vec!["systemd".to_string(), "service".to_string()]),
|
||||
);
|
||||
} else {
|
||||
actions.push(
|
||||
PluginItem::new(
|
||||
format!("systemd:start:{}", unit_name),
|
||||
"▶ Start",
|
||||
format!("systemctl --user start {}", unit_name),
|
||||
)
|
||||
.with_description(format!("Start {}", display_name))
|
||||
.with_icon("media-playback-start")
|
||||
.with_keywords(vec!["systemd".to_string(), "service".to_string()]),
|
||||
);
|
||||
}
|
||||
|
||||
// Always available actions
|
||||
actions.push(
|
||||
PluginItem::new(
|
||||
format!("systemd:status:{}", unit_name),
|
||||
"ℹ Status",
|
||||
format!("systemctl --user status {}", unit_name),
|
||||
)
|
||||
.with_description(format!("Show {} status", display_name))
|
||||
.with_icon("dialog-information")
|
||||
.with_keywords(vec!["systemd".to_string(), "service".to_string()])
|
||||
.with_terminal(true),
|
||||
);
|
||||
|
||||
actions.push(
|
||||
PluginItem::new(
|
||||
format!("systemd:journal:{}", unit_name),
|
||||
"📋 Journal",
|
||||
format!("journalctl --user -u {} -f", unit_name),
|
||||
)
|
||||
.with_description(format!("Show {} logs", display_name))
|
||||
.with_icon("utilities-system-monitor")
|
||||
.with_keywords(vec!["systemd".to_string(), "service".to_string()])
|
||||
.with_terminal(true),
|
||||
);
|
||||
|
||||
actions.push(
|
||||
PluginItem::new(
|
||||
format!("systemd:enable:{}", unit_name),
|
||||
"⊕ Enable",
|
||||
format!("systemctl --user enable {}", unit_name),
|
||||
)
|
||||
.with_description(format!("Enable {} on startup", display_name))
|
||||
.with_icon("emblem-default")
|
||||
.with_keywords(vec!["systemd".to_string(), "service".to_string()]),
|
||||
);
|
||||
|
||||
actions.push(
|
||||
PluginItem::new(
|
||||
format!("systemd:disable:{}", unit_name),
|
||||
"⊖ Disable",
|
||||
format!("systemctl --user disable {}", unit_name),
|
||||
)
|
||||
.with_description(format!("Disable {} on startup", display_name))
|
||||
.with_icon("emblem-unreadable")
|
||||
.with_keywords(vec!["systemd".to_string(), "service".to_string()]),
|
||||
);
|
||||
|
||||
actions
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Interface Implementation
|
||||
// ============================================================================
|
||||
|
||||
extern "C" fn plugin_info() -> PluginInfo {
|
||||
PluginInfo {
|
||||
id: RString::from(PLUGIN_ID),
|
||||
name: RString::from(PLUGIN_NAME),
|
||||
version: RString::from(PLUGIN_VERSION),
|
||||
description: RString::from(PLUGIN_DESCRIPTION),
|
||||
api_version: API_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
|
||||
vec![ProviderInfo {
|
||||
id: RString::from(PROVIDER_ID),
|
||||
name: RString::from(PROVIDER_NAME),
|
||||
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
|
||||
icon: RString::from(PROVIDER_ICON),
|
||||
provider_type: ProviderKind::Static,
|
||||
type_id: RString::from(PROVIDER_TYPE_ID),
|
||||
position: ProviderPosition::Normal,
|
||||
priority: 0, // Static: use frecency ordering
|
||||
}]
|
||||
.into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
|
||||
let state = Box::new(SystemdState::new());
|
||||
ProviderHandle::from_box(state)
|
||||
}
|
||||
|
||||
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
|
||||
if handle.ptr.is_null() {
|
||||
return RVec::new();
|
||||
}
|
||||
|
||||
// SAFETY: We created this handle from Box<SystemdState>
|
||||
let state = unsafe { &mut *(handle.ptr as *mut SystemdState) };
|
||||
|
||||
state.refresh();
|
||||
state.items.clone().into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_query(_handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem> {
|
||||
let query_str = query.as_str();
|
||||
|
||||
// Handle submenu action requests: ?SUBMENU:unit.service:is_active
|
||||
if let Some(data) = query_str.strip_prefix("?SUBMENU:") {
|
||||
// Parse data format: "unit_name:is_active"
|
||||
let parts: Vec<&str> = data.splitn(2, ':').collect();
|
||||
if parts.len() >= 2 {
|
||||
let unit_name = parts[0];
|
||||
let is_active = parts[1] == "true";
|
||||
let display_name = unit_name
|
||||
.trim_end_matches(".service")
|
||||
.replace("app-", "")
|
||||
.replace("@autostart", "")
|
||||
.replace("\\x2d", "-");
|
||||
|
||||
return actions_for_service(unit_name, &display_name, is_active).into();
|
||||
} else if !data.is_empty() {
|
||||
// Fallback: just unit name, assume not active
|
||||
let display_name = data
|
||||
.trim_end_matches(".service")
|
||||
.replace("app-", "")
|
||||
.replace("@autostart", "")
|
||||
.replace("\\x2d", "-");
|
||||
return actions_for_service(data, &display_name, false).into();
|
||||
}
|
||||
}
|
||||
|
||||
// Static provider - normal queries not used
|
||||
RVec::new()
|
||||
}
|
||||
|
||||
extern "C" fn provider_drop(handle: ProviderHandle) {
|
||||
if !handle.ptr.is_null() {
|
||||
// SAFETY: We created this handle from Box<SystemdState>
|
||||
unsafe {
|
||||
handle.drop_as::<SystemdState>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the plugin vtable
|
||||
owlry_plugin! {
|
||||
info: plugin_info,
|
||||
providers: plugin_providers,
|
||||
init: provider_init,
|
||||
refresh: provider_refresh,
|
||||
query: provider_query,
|
||||
drop: provider_drop,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_systemctl_output() {
|
||||
let output = r#"
|
||||
foo.service loaded active running Foo Service
|
||||
bar.service loaded inactive dead Bar Service
|
||||
baz@autostart.service loaded active running Baz App
|
||||
"#;
|
||||
let items = SystemdState::parse_systemctl_output(output);
|
||||
assert_eq!(items.len(), 3);
|
||||
|
||||
// Check first item
|
||||
assert_eq!(items[0].name.as_str(), "foo");
|
||||
assert!(items[0].command.as_str().contains("SUBMENU:uuctl:foo.service:true"));
|
||||
|
||||
// Check second item (inactive)
|
||||
assert_eq!(items[1].name.as_str(), "bar");
|
||||
assert!(items[1].command.as_str().contains("SUBMENU:uuctl:bar.service:false"));
|
||||
|
||||
// Check third item (cleaned name)
|
||||
assert_eq!(items[2].name.as_str(), "baz");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_actions_for_active_service() {
|
||||
let actions = actions_for_service("test.service", "Test", true);
|
||||
|
||||
// Active services should have restart, stop, reload, kill + common actions
|
||||
let action_ids: Vec<_> = actions.iter().map(|a| a.id.as_str()).collect();
|
||||
assert!(action_ids.contains(&"systemd:restart:test.service"));
|
||||
assert!(action_ids.contains(&"systemd:stop:test.service"));
|
||||
assert!(action_ids.contains(&"systemd:status:test.service"));
|
||||
assert!(!action_ids.contains(&"systemd:start:test.service")); // Not for active
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_actions_for_inactive_service() {
|
||||
let actions = actions_for_service("test.service", "Test", false);
|
||||
|
||||
// Inactive services should have start + common actions
|
||||
let action_ids: Vec<_> = actions.iter().map(|a| a.id.as_str()).collect();
|
||||
assert!(action_ids.contains(&"systemd:start:test.service"));
|
||||
assert!(action_ids.contains(&"systemd:status:test.service"));
|
||||
assert!(!action_ids.contains(&"systemd:stop:test.service")); // Not for inactive
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_terminal_actions() {
|
||||
let actions = actions_for_service("test.service", "Test", true);
|
||||
|
||||
// Status and journal should have terminal=true
|
||||
for action in &actions {
|
||||
let id = action.id.as_str();
|
||||
if id.contains(":status:") || id.contains(":journal:") {
|
||||
assert!(action.terminal, "Action {} should have terminal=true", id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_submenu_query() {
|
||||
// Test that provider_query handles ?SUBMENU: queries correctly
|
||||
let handle = ProviderHandle { ptr: std::ptr::null_mut() };
|
||||
|
||||
// Query for active service
|
||||
let query = RStr::from_str("?SUBMENU:test.service:true");
|
||||
let actions = provider_query(handle, query);
|
||||
assert!(!actions.is_empty(), "Should return actions for submenu query");
|
||||
|
||||
// Should have restart action for active service
|
||||
let has_restart = actions.iter().any(|a| a.id.as_str().contains(":restart:"));
|
||||
assert!(has_restart, "Active service should have restart action");
|
||||
|
||||
// Query for inactive service
|
||||
let query = RStr::from_str("?SUBMENU:test.service:false");
|
||||
let actions = provider_query(handle, query);
|
||||
assert!(!actions.is_empty(), "Should return actions for submenu query");
|
||||
|
||||
// Should have start action for inactive service
|
||||
let has_start = actions.iter().any(|a| a.id.as_str().contains(":start:"));
|
||||
assert!(has_start, "Inactive service should have start action");
|
||||
|
||||
// Normal query should return empty
|
||||
let query = RStr::from_str("some search");
|
||||
let actions = provider_query(handle, query);
|
||||
assert!(actions.is_empty(), "Normal query should return empty");
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
[package]
|
||||
name = "owlry-plugin-weather"
|
||||
version = "0.4.10"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Weather widget plugin for owlry - shows current weather with multiple API support"
|
||||
keywords = ["owlry", "plugin", "weather", "widget"]
|
||||
categories = ["gui"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
|
||||
# HTTP client for weather API requests
|
||||
reqwest = { version = "0.13", features = ["blocking", "json"] }
|
||||
|
||||
# JSON parsing for API responses
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# TOML config parsing
|
||||
toml = "0.8"
|
||||
|
||||
# XDG directories for cache persistence
|
||||
dirs = "5.0"
|
||||
@@ -1,754 +0,0 @@
|
||||
//! Weather Widget Plugin for Owlry
|
||||
//!
|
||||
//! Shows current weather with support for multiple APIs:
|
||||
//! - wttr.in (default, no API key required)
|
||||
//! - OpenWeatherMap (requires API key)
|
||||
//! - Open-Meteo (no API key required)
|
||||
//!
|
||||
//! Weather data is cached for 15 minutes.
|
||||
//!
|
||||
//! ## Configuration
|
||||
//!
|
||||
//! Configure via `~/.config/owlry/config.toml`:
|
||||
//!
|
||||
//! ```toml
|
||||
//! [plugins.weather]
|
||||
//! provider = "wttr.in" # or: openweathermap, open-meteo
|
||||
//! location = "Berlin" # city name or "lat,lon"
|
||||
//! # api_key = "..." # Required for OpenWeatherMap
|
||||
//! ```
|
||||
|
||||
use abi_stable::std_types::{ROption, RStr, RString, RVec};
|
||||
use owlry_plugin_api::{
|
||||
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
|
||||
ProviderPosition, API_VERSION,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
// Plugin metadata
|
||||
const PLUGIN_ID: &str = "weather";
|
||||
const PLUGIN_NAME: &str = "Weather";
|
||||
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const PLUGIN_DESCRIPTION: &str = "Weather widget with multiple API support";
|
||||
|
||||
// Provider metadata
|
||||
const PROVIDER_ID: &str = "weather";
|
||||
const PROVIDER_NAME: &str = "Weather";
|
||||
const PROVIDER_ICON: &str = "weather-clear";
|
||||
const PROVIDER_TYPE_ID: &str = "weather";
|
||||
|
||||
// Timing constants
|
||||
const CACHE_DURATION_SECS: u64 = 900; // 15 minutes
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const USER_AGENT: &str = "owlry-launcher/0.3";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum WeatherProviderType {
|
||||
WttrIn,
|
||||
OpenWeatherMap,
|
||||
OpenMeteo,
|
||||
}
|
||||
|
||||
impl std::str::FromStr for WeatherProviderType {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"wttr.in" | "wttr" | "wttrin" => Ok(Self::WttrIn),
|
||||
"openweathermap" | "owm" => Ok(Self::OpenWeatherMap),
|
||||
"open-meteo" | "openmeteo" | "meteo" => Ok(Self::OpenMeteo),
|
||||
_ => Err(format!("Unknown weather provider: {}", s)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct WeatherConfig {
|
||||
provider: WeatherProviderType,
|
||||
api_key: Option<String>,
|
||||
location: String,
|
||||
}
|
||||
|
||||
impl WeatherConfig {
|
||||
/// Load config from ~/.config/owlry/config.toml
|
||||
///
|
||||
/// Reads from [plugins.weather] section, with fallback to [providers] for compatibility.
|
||||
fn load() -> Self {
|
||||
let config_path = dirs::config_dir()
|
||||
.map(|d| d.join("owlry").join("config.toml"));
|
||||
|
||||
let config_content = config_path
|
||||
.and_then(|p| fs::read_to_string(p).ok());
|
||||
|
||||
if let Some(content) = config_content
|
||||
&& let Ok(toml) = content.parse::<toml::Table>()
|
||||
{
|
||||
// Try [plugins.weather] first (new format)
|
||||
if let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table())
|
||||
&& let Some(weather) = plugins.get("weather").and_then(|v| v.as_table())
|
||||
{
|
||||
return Self::from_toml_table(weather);
|
||||
}
|
||||
|
||||
// Fallback to [providers] section (old format)
|
||||
if let Some(providers) = toml.get("providers").and_then(|v| v.as_table()) {
|
||||
let provider_str = providers
|
||||
.get("weather_provider")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("wttr.in");
|
||||
|
||||
let provider = provider_str.parse().unwrap_or(WeatherProviderType::WttrIn);
|
||||
|
||||
let api_key = providers
|
||||
.get("weather_api_key")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let location = providers
|
||||
.get("weather_location")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
return Self {
|
||||
provider,
|
||||
api_key,
|
||||
location,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Default config
|
||||
Self {
|
||||
provider: WeatherProviderType::WttrIn,
|
||||
api_key: None,
|
||||
location: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse config from a TOML table
|
||||
fn from_toml_table(table: &toml::Table) -> Self {
|
||||
let provider_str = table
|
||||
.get("provider")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("wttr.in");
|
||||
|
||||
let provider = provider_str.parse().unwrap_or(WeatherProviderType::WttrIn);
|
||||
|
||||
let api_key = table
|
||||
.get("api_key")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let location = table
|
||||
.get("location")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Self {
|
||||
provider,
|
||||
api_key,
|
||||
location,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached weather data (persisted to disk)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct WeatherData {
|
||||
temperature: f32,
|
||||
feels_like: Option<f32>,
|
||||
condition: String,
|
||||
humidity: Option<u8>,
|
||||
wind_speed: Option<f32>,
|
||||
icon: String,
|
||||
location: String,
|
||||
}
|
||||
|
||||
/// Persistent cache structure (saved to ~/.local/share/owlry/weather_cache.json)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct WeatherCache {
|
||||
last_fetch_epoch: u64,
|
||||
data: WeatherData,
|
||||
}
|
||||
|
||||
/// Weather provider state
|
||||
struct WeatherState {
|
||||
items: Vec<PluginItem>,
|
||||
config: WeatherConfig,
|
||||
last_fetch_epoch: u64,
|
||||
cached_data: Option<WeatherData>,
|
||||
}
|
||||
|
||||
impl WeatherState {
|
||||
fn new() -> Self {
|
||||
Self::with_config(WeatherConfig::load())
|
||||
}
|
||||
|
||||
fn with_config(config: WeatherConfig) -> Self {
|
||||
// Load cached weather from disk if available
|
||||
// This prevents blocking HTTP requests on every app open
|
||||
let (last_fetch_epoch, cached_data) = Self::load_cache()
|
||||
.map(|c| (c.last_fetch_epoch, Some(c.data)))
|
||||
.unwrap_or((0, None));
|
||||
|
||||
Self {
|
||||
items: Vec::new(),
|
||||
config,
|
||||
last_fetch_epoch,
|
||||
cached_data,
|
||||
}
|
||||
}
|
||||
|
||||
fn data_dir() -> Option<PathBuf> {
|
||||
dirs::data_dir().map(|d| d.join("owlry"))
|
||||
}
|
||||
|
||||
fn cache_path() -> Option<PathBuf> {
|
||||
Self::data_dir().map(|d| d.join("weather_cache.json"))
|
||||
}
|
||||
|
||||
fn load_cache() -> Option<WeatherCache> {
|
||||
let path = Self::cache_path()?;
|
||||
let content = fs::read_to_string(&path).ok()?;
|
||||
serde_json::from_str(&content).ok()
|
||||
}
|
||||
|
||||
fn save_cache(&self) {
|
||||
if let (Some(data_dir), Some(cache_path), Some(data)) =
|
||||
(Self::data_dir(), Self::cache_path(), &self.cached_data)
|
||||
{
|
||||
if fs::create_dir_all(&data_dir).is_err() {
|
||||
return;
|
||||
}
|
||||
let cache = WeatherCache {
|
||||
last_fetch_epoch: self.last_fetch_epoch,
|
||||
data: data.clone(),
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string_pretty(&cache) {
|
||||
let _ = fs::write(&cache_path, json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn now_epoch() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn is_cache_valid(&self) -> bool {
|
||||
if self.last_fetch_epoch == 0 {
|
||||
return false;
|
||||
}
|
||||
let now = Self::now_epoch();
|
||||
now.saturating_sub(self.last_fetch_epoch) < CACHE_DURATION_SECS
|
||||
}
|
||||
|
||||
fn refresh(&mut self) {
|
||||
// Use cache if still valid (works across app restarts)
|
||||
if self.is_cache_valid()
|
||||
&& let Some(data) = self.cached_data.clone() {
|
||||
self.generate_items(&data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch new data from API
|
||||
if let Some(data) = self.fetch_weather() {
|
||||
self.cached_data = Some(data.clone());
|
||||
self.last_fetch_epoch = Self::now_epoch();
|
||||
self.save_cache(); // Persist to disk for next app open
|
||||
self.generate_items(&data);
|
||||
} else {
|
||||
// On fetch failure, try to use stale cache if available
|
||||
if let Some(data) = self.cached_data.clone() {
|
||||
self.generate_items(&data);
|
||||
} else {
|
||||
self.items.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_weather(&self) -> Option<WeatherData> {
|
||||
match self.config.provider {
|
||||
WeatherProviderType::WttrIn => self.fetch_wttr_in(),
|
||||
WeatherProviderType::OpenWeatherMap => self.fetch_openweathermap(),
|
||||
WeatherProviderType::OpenMeteo => self.fetch_open_meteo(),
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_wttr_in(&self) -> Option<WeatherData> {
|
||||
let location = if self.config.location.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
self.config.location.clone()
|
||||
};
|
||||
|
||||
let url = format!("https://wttr.in/{}?format=j1", location);
|
||||
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.user_agent(USER_AGENT)
|
||||
.build()
|
||||
.ok()?;
|
||||
|
||||
let response = client.get(&url).send().ok()?;
|
||||
let json: WttrInResponse = response.json().ok()?;
|
||||
|
||||
let current = json.current_condition.first()?;
|
||||
let nearest = json.nearest_area.first()?;
|
||||
|
||||
let location_name = nearest
|
||||
.area_name
|
||||
.first()
|
||||
.map(|a| a.value.clone())
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
|
||||
Some(WeatherData {
|
||||
temperature: current.temp_c.parse().unwrap_or(0.0),
|
||||
feels_like: current.feels_like_c.parse().ok(),
|
||||
condition: current
|
||||
.weather_desc
|
||||
.first()
|
||||
.map(|d| d.value.clone())
|
||||
.unwrap_or_else(|| "Unknown".to_string()),
|
||||
humidity: current.humidity.parse().ok(),
|
||||
wind_speed: current.windspeed_kmph.parse().ok(),
|
||||
icon: Self::wttr_code_to_icon(¤t.weather_code),
|
||||
location: location_name,
|
||||
})
|
||||
}
|
||||
|
||||
fn fetch_openweathermap(&self) -> Option<WeatherData> {
|
||||
let api_key = self.config.api_key.as_ref()?;
|
||||
if self.config.location.is_empty() {
|
||||
return None; // OWM requires a location
|
||||
}
|
||||
|
||||
let url = format!(
|
||||
"https://api.openweathermap.org/data/2.5/weather?q={}&appid={}&units=metric",
|
||||
self.config.location, api_key
|
||||
);
|
||||
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.build()
|
||||
.ok()?;
|
||||
|
||||
let response = client.get(&url).send().ok()?;
|
||||
let json: OpenWeatherMapResponse = response.json().ok()?;
|
||||
|
||||
let weather = json.weather.first()?;
|
||||
|
||||
Some(WeatherData {
|
||||
temperature: json.main.temp,
|
||||
feels_like: Some(json.main.feels_like),
|
||||
condition: weather.description.clone(),
|
||||
humidity: Some(json.main.humidity),
|
||||
wind_speed: Some(json.wind.speed * 3.6), // m/s to km/h
|
||||
icon: Self::owm_icon_to_freedesktop(&weather.icon),
|
||||
location: json.name,
|
||||
})
|
||||
}
|
||||
|
||||
fn fetch_open_meteo(&self) -> Option<WeatherData> {
|
||||
let (lat, lon, location_name) = self.get_coordinates()?;
|
||||
|
||||
let url = format!(
|
||||
"https://api.open-meteo.com/v1/forecast?latitude={}&longitude={}¤t=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m&timezone=auto",
|
||||
lat, lon
|
||||
);
|
||||
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.build()
|
||||
.ok()?;
|
||||
|
||||
let response = client.get(&url).send().ok()?;
|
||||
let json: OpenMeteoResponse = response.json().ok()?;
|
||||
|
||||
let current = json.current;
|
||||
|
||||
Some(WeatherData {
|
||||
temperature: current.temperature_2m,
|
||||
feels_like: None,
|
||||
condition: Self::wmo_code_to_description(current.weather_code),
|
||||
humidity: Some(current.relative_humidity_2m as u8),
|
||||
wind_speed: Some(current.wind_speed_10m),
|
||||
icon: Self::wmo_code_to_icon(current.weather_code),
|
||||
location: location_name,
|
||||
})
|
||||
}
|
||||
|
||||
fn get_coordinates(&self) -> Option<(f64, f64, String)> {
|
||||
let location = &self.config.location;
|
||||
|
||||
// Check if location is already coordinates (lat,lon)
|
||||
if location.contains(',') {
|
||||
let parts: Vec<&str> = location.split(',').collect();
|
||||
if parts.len() == 2
|
||||
&& let (Ok(lat), Ok(lon)) = (
|
||||
parts[0].trim().parse::<f64>(),
|
||||
parts[1].trim().parse::<f64>(),
|
||||
) {
|
||||
return Some((lat, lon, location.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// Use Open-Meteo geocoding API
|
||||
let url = format!(
|
||||
"https://geocoding-api.open-meteo.com/v1/search?name={}&count=1",
|
||||
location
|
||||
);
|
||||
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.build()
|
||||
.ok()?;
|
||||
|
||||
let response = client.get(&url).send().ok()?;
|
||||
let json: GeocodingResponse = response.json().ok()?;
|
||||
|
||||
let result = json.results?.into_iter().next()?;
|
||||
Some((result.latitude, result.longitude, result.name))
|
||||
}
|
||||
|
||||
fn wttr_code_to_icon(code: &str) -> String {
|
||||
match code {
|
||||
"113" => "weather-clear",
|
||||
"116" => "weather-few-clouds",
|
||||
"119" => "weather-overcast",
|
||||
"122" => "weather-overcast",
|
||||
"143" | "248" | "260" => "weather-fog",
|
||||
"176" | "263" | "266" | "293" | "296" | "299" | "302" | "305" | "308" => {
|
||||
"weather-showers"
|
||||
}
|
||||
"179" | "182" | "185" | "227" | "230" | "323" | "326" | "329" | "332" | "335"
|
||||
| "338" | "350" | "368" | "371" | "374" | "377" => "weather-snow",
|
||||
"200" | "386" | "389" | "392" | "395" => "weather-storm",
|
||||
_ => "weather-clear",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn owm_icon_to_freedesktop(icon: &str) -> String {
|
||||
match icon {
|
||||
"01d" | "01n" => "weather-clear",
|
||||
"02d" | "02n" => "weather-few-clouds",
|
||||
"03d" | "03n" | "04d" | "04n" => "weather-overcast",
|
||||
"09d" | "09n" | "10d" | "10n" => "weather-showers",
|
||||
"11d" | "11n" => "weather-storm",
|
||||
"13d" | "13n" => "weather-snow",
|
||||
"50d" | "50n" => "weather-fog",
|
||||
_ => "weather-clear",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn wmo_code_to_description(code: i32) -> String {
|
||||
match code {
|
||||
0 => "Clear sky",
|
||||
1 => "Mainly clear",
|
||||
2 => "Partly cloudy",
|
||||
3 => "Overcast",
|
||||
45 | 48 => "Foggy",
|
||||
51 | 53 | 55 => "Drizzle",
|
||||
61 | 63 | 65 => "Rain",
|
||||
66 | 67 => "Freezing rain",
|
||||
71 | 73 | 75 | 77 => "Snow",
|
||||
80..=82 => "Rain showers",
|
||||
85 | 86 => "Snow showers",
|
||||
95 | 96 | 99 => "Thunderstorm",
|
||||
_ => "Unknown",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn wmo_code_to_icon(code: i32) -> String {
|
||||
match code {
|
||||
0 | 1 => "weather-clear",
|
||||
2 => "weather-few-clouds",
|
||||
3 => "weather-overcast",
|
||||
45 | 48 => "weather-fog",
|
||||
51 | 53 | 55 | 61 | 63 | 65 | 80 | 81 | 82 => "weather-showers",
|
||||
66 | 67 | 71 | 73 | 75 | 77 | 85 | 86 => "weather-snow",
|
||||
95 | 96 | 99 => "weather-storm",
|
||||
_ => "weather-clear",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn icon_to_resource_path(icon: &str) -> String {
|
||||
let weather_icon = if icon.contains("clear") {
|
||||
"wi-day-sunny"
|
||||
} else if icon.contains("few-clouds") {
|
||||
"wi-day-cloudy"
|
||||
} else if icon.contains("overcast") || icon.contains("clouds") {
|
||||
"wi-cloudy"
|
||||
} else if icon.contains("fog") {
|
||||
"wi-fog"
|
||||
} else if icon.contains("showers") || icon.contains("rain") {
|
||||
"wi-rain"
|
||||
} else if icon.contains("snow") {
|
||||
"wi-snow"
|
||||
} else if icon.contains("storm") {
|
||||
"wi-thunderstorm"
|
||||
} else {
|
||||
"wi-thermometer"
|
||||
};
|
||||
format!("/org/owlry/launcher/icons/weather/{}.svg", weather_icon)
|
||||
}
|
||||
|
||||
fn generate_items(&mut self, data: &WeatherData) {
|
||||
self.items.clear();
|
||||
|
||||
let temp_str = format!("{}°C", data.temperature.round() as i32);
|
||||
let name = format!("{} {}", temp_str, data.condition);
|
||||
|
||||
let mut details = vec![data.location.clone()];
|
||||
if let Some(humidity) = data.humidity {
|
||||
details.push(format!("Humidity {}%", humidity));
|
||||
}
|
||||
if let Some(wind) = data.wind_speed {
|
||||
details.push(format!("Wind {} km/h", wind.round() as i32));
|
||||
}
|
||||
if let Some(feels) = data.feels_like
|
||||
&& (feels - data.temperature).abs() > 2.0 {
|
||||
details.push(format!("Feels like {}°C", feels.round() as i32));
|
||||
}
|
||||
|
||||
let encoded_location = data.location.replace(' ', "+");
|
||||
let command = format!("xdg-open 'https://wttr.in/{}'", encoded_location);
|
||||
|
||||
self.items.push(
|
||||
PluginItem::new("weather-current", name, command)
|
||||
.with_description(details.join(" | "))
|
||||
.with_icon(Self::icon_to_resource_path(&data.icon))
|
||||
.with_keywords(vec!["weather".to_string(), "widget".to_string()]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Response Types
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WttrInResponse {
|
||||
current_condition: Vec<WttrInCurrent>,
|
||||
nearest_area: Vec<WttrInArea>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WttrInCurrent {
|
||||
#[serde(rename = "temp_C")]
|
||||
temp_c: String,
|
||||
#[serde(rename = "FeelsLikeC")]
|
||||
feels_like_c: String,
|
||||
humidity: String,
|
||||
#[serde(rename = "weatherCode")]
|
||||
weather_code: String,
|
||||
#[serde(rename = "weatherDesc")]
|
||||
weather_desc: Vec<WttrInValue>,
|
||||
#[serde(rename = "windspeedKmph")]
|
||||
windspeed_kmph: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WttrInValue {
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WttrInArea {
|
||||
#[serde(rename = "areaName")]
|
||||
area_name: Vec<WttrInValue>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenWeatherMapResponse {
|
||||
main: OwmMain,
|
||||
weather: Vec<OwmWeather>,
|
||||
wind: OwmWind,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OwmMain {
|
||||
temp: f32,
|
||||
feels_like: f32,
|
||||
humidity: u8,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OwmWeather {
|
||||
description: String,
|
||||
icon: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OwmWind {
|
||||
speed: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenMeteoResponse {
|
||||
current: OpenMeteoCurrent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenMeteoCurrent {
|
||||
temperature_2m: f32,
|
||||
relative_humidity_2m: f32,
|
||||
weather_code: i32,
|
||||
wind_speed_10m: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GeocodingResponse {
|
||||
results: Option<Vec<GeocodingResult>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GeocodingResult {
|
||||
name: String,
|
||||
latitude: f64,
|
||||
longitude: f64,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Interface Implementation
|
||||
// ============================================================================
|
||||
|
||||
extern "C" fn plugin_info() -> PluginInfo {
|
||||
PluginInfo {
|
||||
id: RString::from(PLUGIN_ID),
|
||||
name: RString::from(PLUGIN_NAME),
|
||||
version: RString::from(PLUGIN_VERSION),
|
||||
description: RString::from(PLUGIN_DESCRIPTION),
|
||||
api_version: API_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
|
||||
vec![ProviderInfo {
|
||||
id: RString::from(PROVIDER_ID),
|
||||
name: RString::from(PROVIDER_NAME),
|
||||
prefix: ROption::RNone,
|
||||
icon: RString::from(PROVIDER_ICON),
|
||||
provider_type: ProviderKind::Static,
|
||||
type_id: RString::from(PROVIDER_TYPE_ID),
|
||||
position: ProviderPosition::Widget,
|
||||
priority: 12000, // Widget: highest priority
|
||||
}]
|
||||
.into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
|
||||
let state = Box::new(WeatherState::new());
|
||||
ProviderHandle::from_box(state)
|
||||
}
|
||||
|
||||
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
|
||||
if handle.ptr.is_null() {
|
||||
return RVec::new();
|
||||
}
|
||||
|
||||
// SAFETY: We created this handle from Box<WeatherState>
|
||||
let state = unsafe { &mut *(handle.ptr as *mut WeatherState) };
|
||||
|
||||
state.refresh();
|
||||
state.items.clone().into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec<PluginItem> {
|
||||
// Static provider - query not used, return empty
|
||||
RVec::new()
|
||||
}
|
||||
|
||||
extern "C" fn provider_drop(handle: ProviderHandle) {
|
||||
if !handle.ptr.is_null() {
|
||||
// SAFETY: We created this handle from Box<WeatherState>
|
||||
unsafe {
|
||||
handle.drop_as::<WeatherState>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the plugin vtable
|
||||
owlry_plugin! {
|
||||
info: plugin_info,
|
||||
providers: plugin_providers,
|
||||
init: provider_init,
|
||||
refresh: provider_refresh,
|
||||
query: provider_query,
|
||||
drop: provider_drop,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_weather_provider_type_from_str() {
|
||||
assert_eq!(
|
||||
"wttr.in".parse::<WeatherProviderType>().unwrap(),
|
||||
WeatherProviderType::WttrIn
|
||||
);
|
||||
assert_eq!(
|
||||
"owm".parse::<WeatherProviderType>().unwrap(),
|
||||
WeatherProviderType::OpenWeatherMap
|
||||
);
|
||||
assert_eq!(
|
||||
"open-meteo".parse::<WeatherProviderType>().unwrap(),
|
||||
WeatherProviderType::OpenMeteo
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wttr_code_to_icon() {
|
||||
assert_eq!(WeatherState::wttr_code_to_icon("113"), "weather-clear");
|
||||
assert_eq!(WeatherState::wttr_code_to_icon("116"), "weather-few-clouds");
|
||||
assert_eq!(WeatherState::wttr_code_to_icon("176"), "weather-showers");
|
||||
assert_eq!(WeatherState::wttr_code_to_icon("200"), "weather-storm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wmo_code_to_description() {
|
||||
assert_eq!(WeatherState::wmo_code_to_description(0), "Clear sky");
|
||||
assert_eq!(WeatherState::wmo_code_to_description(3), "Overcast");
|
||||
assert_eq!(WeatherState::wmo_code_to_description(95), "Thunderstorm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_icon_to_resource_path() {
|
||||
assert_eq!(
|
||||
WeatherState::icon_to_resource_path("weather-clear"),
|
||||
"/org/owlry/launcher/icons/weather/wi-day-sunny.svg"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_validity() {
|
||||
let state = WeatherState {
|
||||
items: Vec::new(),
|
||||
config: WeatherConfig {
|
||||
provider: WeatherProviderType::WttrIn,
|
||||
api_key: None,
|
||||
location: String::new(),
|
||||
},
|
||||
last_fetch_epoch: 0,
|
||||
cached_data: None,
|
||||
};
|
||||
assert!(!state.is_cache_valid());
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
[package]
|
||||
name = "owlry-plugin-websearch"
|
||||
version = "0.4.10"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Web search plugin for owlry - search the web with configurable search engines"
|
||||
keywords = ["owlry", "plugin", "websearch", "search"]
|
||||
categories = ["web-programming"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # Compile as dynamic library (.so)
|
||||
|
||||
[dependencies]
|
||||
# Plugin API for owlry
|
||||
owlry-plugin-api = { path = "../owlry-plugin-api" }
|
||||
|
||||
# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc)
|
||||
abi_stable = "0.11"
|
||||
@@ -1,299 +0,0 @@
|
||||
//! Web Search Plugin for Owlry
|
||||
//!
|
||||
//! A dynamic provider that opens web searches in the browser.
|
||||
//! Supports multiple search engines.
|
||||
//!
|
||||
//! Examples:
|
||||
//! - `? rust programming` → Search DuckDuckGo for "rust programming"
|
||||
//! - `web rust docs` → Search for "rust docs"
|
||||
//! - `search how to rust` → Search for "how to rust"
|
||||
|
||||
use abi_stable::std_types::{ROption, RStr, RString, RVec};
|
||||
use owlry_plugin_api::{
|
||||
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
|
||||
ProviderPosition, API_VERSION,
|
||||
};
|
||||
|
||||
// Plugin metadata
|
||||
const PLUGIN_ID: &str = "websearch";
|
||||
const PLUGIN_NAME: &str = "Web Search";
|
||||
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const PLUGIN_DESCRIPTION: &str = "Search the web with configurable search engines";
|
||||
|
||||
// Provider metadata
|
||||
const PROVIDER_ID: &str = "websearch";
|
||||
const PROVIDER_NAME: &str = "Web Search";
|
||||
const PROVIDER_PREFIX: &str = "?";
|
||||
const PROVIDER_ICON: &str = "web-browser";
|
||||
const PROVIDER_TYPE_ID: &str = "websearch";
|
||||
|
||||
/// Common search engine URL templates
|
||||
/// {query} is replaced with the URL-encoded search term
|
||||
const SEARCH_ENGINES: &[(&str, &str)] = &[
|
||||
("google", "https://www.google.com/search?q={query}"),
|
||||
("duckduckgo", "https://duckduckgo.com/?q={query}"),
|
||||
("bing", "https://www.bing.com/search?q={query}"),
|
||||
("startpage", "https://www.startpage.com/search?q={query}"),
|
||||
("searxng", "https://searx.be/search?q={query}"),
|
||||
("brave", "https://search.brave.com/search?q={query}"),
|
||||
("ecosia", "https://www.ecosia.org/search?q={query}"),
|
||||
];
|
||||
|
||||
/// Default search engine if not configured
|
||||
const DEFAULT_ENGINE: &str = "duckduckgo";
|
||||
|
||||
/// Web search provider state
|
||||
struct WebSearchState {
|
||||
/// URL template with {query} placeholder
|
||||
url_template: String,
|
||||
}
|
||||
|
||||
impl WebSearchState {
|
||||
fn new() -> Self {
|
||||
Self::with_engine(DEFAULT_ENGINE)
|
||||
}
|
||||
|
||||
fn with_engine(engine_name: &str) -> Self {
|
||||
let url_template = SEARCH_ENGINES
|
||||
.iter()
|
||||
.find(|(name, _)| *name == engine_name.to_lowercase())
|
||||
.map(|(_, url)| url.to_string())
|
||||
.unwrap_or_else(|| {
|
||||
// If not a known engine, treat it as a custom URL template
|
||||
if engine_name.contains("{query}") {
|
||||
engine_name.to_string()
|
||||
} else {
|
||||
// Fall back to default
|
||||
SEARCH_ENGINES
|
||||
.iter()
|
||||
.find(|(name, _)| *name == DEFAULT_ENGINE)
|
||||
.map(|(_, url)| url.to_string())
|
||||
.unwrap()
|
||||
}
|
||||
});
|
||||
|
||||
Self { url_template }
|
||||
}
|
||||
|
||||
/// Extract the search term from the query
|
||||
fn extract_search_term(query: &str) -> Option<&str> {
|
||||
let trimmed = query.trim();
|
||||
|
||||
if let Some(rest) = trimmed.strip_prefix("? ") {
|
||||
Some(rest.trim())
|
||||
} else if let Some(rest) = trimmed.strip_prefix("?") {
|
||||
Some(rest.trim())
|
||||
} else if trimmed.to_lowercase().starts_with("web ") {
|
||||
Some(trimmed[4..].trim())
|
||||
} else if trimmed.to_lowercase().starts_with("search ") {
|
||||
Some(trimmed[7..].trim())
|
||||
} else {
|
||||
// In filter mode, accept raw query
|
||||
Some(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
/// URL-encode a search query
|
||||
fn url_encode(query: &str) -> String {
|
||||
query
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
' ' => "+".to_string(),
|
||||
'&' => "%26".to_string(),
|
||||
'=' => "%3D".to_string(),
|
||||
'?' => "%3F".to_string(),
|
||||
'#' => "%23".to_string(),
|
||||
'+' => "%2B".to_string(),
|
||||
'%' => "%25".to_string(),
|
||||
c if c.is_ascii_alphanumeric() || "-_.~".contains(c) => c.to_string(),
|
||||
c => format!("%{:02X}", c as u32),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build the search URL from a query
|
||||
fn build_search_url(&self, search_term: &str) -> String {
|
||||
let encoded = Self::url_encode(search_term);
|
||||
self.url_template.replace("{query}", &encoded)
|
||||
}
|
||||
|
||||
/// Evaluate a query and return a PluginItem if valid
|
||||
fn evaluate(&self, query: &str) -> Option<PluginItem> {
|
||||
let search_term = Self::extract_search_term(query)?;
|
||||
|
||||
if search_term.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let url = self.build_search_url(search_term);
|
||||
|
||||
// Use xdg-open to open the browser
|
||||
let command = format!("xdg-open '{}'", url);
|
||||
|
||||
Some(
|
||||
PluginItem::new(
|
||||
format!("websearch:{}", search_term),
|
||||
format!("Search: {}", search_term),
|
||||
command,
|
||||
)
|
||||
.with_description("Open in browser")
|
||||
.with_icon(PROVIDER_ICON)
|
||||
.with_keywords(vec!["web".to_string(), "search".to_string()]),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Interface Implementation
|
||||
// ============================================================================
|
||||
|
||||
extern "C" fn plugin_info() -> PluginInfo {
|
||||
PluginInfo {
|
||||
id: RString::from(PLUGIN_ID),
|
||||
name: RString::from(PLUGIN_NAME),
|
||||
version: RString::from(PLUGIN_VERSION),
|
||||
description: RString::from(PLUGIN_DESCRIPTION),
|
||||
api_version: API_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
|
||||
vec![ProviderInfo {
|
||||
id: RString::from(PROVIDER_ID),
|
||||
name: RString::from(PROVIDER_NAME),
|
||||
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
|
||||
icon: RString::from(PROVIDER_ICON),
|
||||
provider_type: ProviderKind::Dynamic,
|
||||
type_id: RString::from(PROVIDER_TYPE_ID),
|
||||
position: ProviderPosition::Normal,
|
||||
priority: 9000, // Dynamic: web search
|
||||
}]
|
||||
.into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
|
||||
// TODO: Read search engine from config when plugin config is available
|
||||
let state = Box::new(WebSearchState::new());
|
||||
ProviderHandle::from_box(state)
|
||||
}
|
||||
|
||||
extern "C" fn provider_refresh(_handle: ProviderHandle) -> RVec<PluginItem> {
|
||||
// Dynamic provider - refresh does nothing
|
||||
RVec::new()
|
||||
}
|
||||
|
||||
extern "C" fn provider_query(handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem> {
|
||||
if handle.ptr.is_null() {
|
||||
return RVec::new();
|
||||
}
|
||||
|
||||
// SAFETY: We created this handle from Box<WebSearchState>
|
||||
let state = unsafe { &*(handle.ptr as *const WebSearchState) };
|
||||
|
||||
let query_str = query.as_str();
|
||||
|
||||
match state.evaluate(query_str) {
|
||||
Some(item) => vec![item].into(),
|
||||
None => RVec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn provider_drop(handle: ProviderHandle) {
|
||||
if !handle.ptr.is_null() {
|
||||
// SAFETY: We created this handle from Box<WebSearchState>
|
||||
unsafe {
|
||||
handle.drop_as::<WebSearchState>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the plugin vtable
|
||||
owlry_plugin! {
|
||||
info: plugin_info,
|
||||
providers: plugin_providers,
|
||||
init: provider_init,
|
||||
refresh: provider_refresh,
|
||||
query: provider_query,
|
||||
drop: provider_drop,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_search_term() {
|
||||
assert_eq!(
|
||||
WebSearchState::extract_search_term("? rust programming"),
|
||||
Some("rust programming")
|
||||
);
|
||||
assert_eq!(
|
||||
WebSearchState::extract_search_term("?rust"),
|
||||
Some("rust")
|
||||
);
|
||||
assert_eq!(
|
||||
WebSearchState::extract_search_term("web rust docs"),
|
||||
Some("rust docs")
|
||||
);
|
||||
assert_eq!(
|
||||
WebSearchState::extract_search_term("search how to rust"),
|
||||
Some("how to rust")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_url_encode() {
|
||||
assert_eq!(WebSearchState::url_encode("hello world"), "hello+world");
|
||||
assert_eq!(WebSearchState::url_encode("foo&bar"), "foo%26bar");
|
||||
assert_eq!(WebSearchState::url_encode("a=b"), "a%3Db");
|
||||
assert_eq!(WebSearchState::url_encode("test?query"), "test%3Fquery");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_search_url() {
|
||||
let state = WebSearchState::with_engine("duckduckgo");
|
||||
let url = state.build_search_url("rust programming");
|
||||
assert_eq!(url, "https://duckduckgo.com/?q=rust+programming");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_search_url_google() {
|
||||
let state = WebSearchState::with_engine("google");
|
||||
let url = state.build_search_url("rust");
|
||||
assert_eq!(url, "https://www.google.com/search?q=rust");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate() {
|
||||
let state = WebSearchState::new();
|
||||
let item = state.evaluate("? rust docs").unwrap();
|
||||
assert_eq!(item.name.as_str(), "Search: rust docs");
|
||||
assert!(item.command.as_str().contains("xdg-open"));
|
||||
assert!(item.command.as_str().contains("duckduckgo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_empty() {
|
||||
let state = WebSearchState::new();
|
||||
assert!(state.evaluate("?").is_none());
|
||||
assert!(state.evaluate("? ").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_url_template() {
|
||||
let state = WebSearchState::with_engine("https://custom.search/q={query}");
|
||||
let url = state.build_search_url("test");
|
||||
assert_eq!(url, "https://custom.search/q=test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_to_default() {
|
||||
let state = WebSearchState::with_engine("nonexistent");
|
||||
let url = state.build_search_url("test");
|
||||
assert!(url.contains("duckduckgo")); // Falls back to default
|
||||
}
|
||||
}
|
||||
318
docs/PLUGINS.md
318
docs/PLUGINS.md
@@ -1,318 +0,0 @@
|
||||
# Available Plugins
|
||||
|
||||
Owlry's functionality is provided through a modular plugin system. This document describes all available plugins.
|
||||
|
||||
## Plugin Categories
|
||||
|
||||
### Static Providers
|
||||
|
||||
Static providers load their items once at startup (and on manual refresh). They're best for data that doesn't change frequently.
|
||||
|
||||
### Dynamic Providers
|
||||
|
||||
Dynamic providers evaluate queries in real-time. Each keystroke triggers a new query, making them ideal for calculations, searches, and other interactive features.
|
||||
|
||||
### Widget Providers
|
||||
|
||||
Widget providers display persistent information at the top of results (weather, media controls, timers).
|
||||
|
||||
---
|
||||
|
||||
## Core Plugins
|
||||
|
||||
### owlry-plugin-calculator
|
||||
|
||||
**Type:** Dynamic
|
||||
**Prefix:** `:calc`, `=`, `calc `
|
||||
**Package:** `owlry-plugin-calculator`
|
||||
|
||||
Evaluate mathematical expressions in real-time.
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
= 5 + 3 → 8
|
||||
= sqrt(16) → 4
|
||||
= sin(pi/2) → 1
|
||||
= 2^10 → 1024
|
||||
= (1 + 0.05)^12 → 1.7958...
|
||||
```
|
||||
|
||||
**Supported operations:**
|
||||
- Basic: `+`, `-`, `*`, `/`, `^` (power), `%` (modulo)
|
||||
- Functions: `sin`, `cos`, `tan`, `asin`, `acos`, `atan`
|
||||
- Functions: `sqrt`, `abs`, `floor`, `ceil`, `round`
|
||||
- Functions: `ln`, `log`, `log10`, `exp`
|
||||
- Constants: `pi`, `e`
|
||||
|
||||
---
|
||||
|
||||
### owlry-plugin-system
|
||||
|
||||
**Type:** Static
|
||||
**Prefix:** `:sys`
|
||||
**Package:** `owlry-plugin-system`
|
||||
|
||||
System power and session management commands.
|
||||
|
||||
**Actions:**
|
||||
| Name | Description | Command |
|
||||
|------|-------------|---------|
|
||||
| Shutdown | Power off | `systemctl poweroff` |
|
||||
| Reboot | Restart | `systemctl reboot` |
|
||||
| Reboot into BIOS | UEFI setup | `systemctl reboot --firmware-setup` |
|
||||
| Suspend | Sleep (RAM) | `systemctl suspend` |
|
||||
| Hibernate | Sleep (disk) | `systemctl hibernate` |
|
||||
| Lock Screen | Lock session | `loginctl lock-session` |
|
||||
| Log Out | End session | `loginctl terminate-session self` |
|
||||
|
||||
---
|
||||
|
||||
### owlry-plugin-ssh
|
||||
|
||||
**Type:** Static
|
||||
**Prefix:** `:ssh`
|
||||
**Package:** `owlry-plugin-ssh`
|
||||
|
||||
SSH hosts parsed from `~/.ssh/config`.
|
||||
|
||||
**Features:**
|
||||
- Parses `Host` entries from SSH config
|
||||
- Ignores wildcards (`Host *`)
|
||||
- Opens connections in your configured terminal
|
||||
|
||||
---
|
||||
|
||||
### owlry-plugin-clipboard
|
||||
|
||||
**Type:** Static
|
||||
**Prefix:** `:clip`
|
||||
**Package:** `owlry-plugin-clipboard`
|
||||
**Dependencies:** `cliphist`, `wl-clipboard`
|
||||
|
||||
Clipboard history integration with cliphist.
|
||||
|
||||
**Features:**
|
||||
- Shows last 50 clipboard entries
|
||||
- Previews text content (truncated to 80 chars)
|
||||
- Select to copy back to clipboard
|
||||
|
||||
---
|
||||
|
||||
### owlry-plugin-emoji
|
||||
|
||||
**Type:** Static
|
||||
**Prefix:** `:emoji`
|
||||
**Package:** `owlry-plugin-emoji`
|
||||
**Dependencies:** `wl-clipboard`
|
||||
|
||||
400+ searchable emoji with keywords.
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
:emoji heart → ❤️ 💙 💚 💜 ...
|
||||
:emoji smile → 😀 😃 😄 😁 ...
|
||||
:emoji fire → 🔥
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### owlry-plugin-scripts
|
||||
|
||||
**Type:** Static
|
||||
**Prefix:** `:script`
|
||||
**Package:** `owlry-plugin-scripts`
|
||||
|
||||
User scripts from `~/.local/share/owlry/scripts/`.
|
||||
|
||||
**Setup:**
|
||||
```bash
|
||||
mkdir -p ~/.local/share/owlry/scripts
|
||||
cat > ~/.local/share/owlry/scripts/backup.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
rsync -av ~/Documents /backup/
|
||||
notify-send "Backup complete"
|
||||
EOF
|
||||
chmod +x ~/.local/share/owlry/scripts/backup.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### owlry-plugin-bookmarks
|
||||
|
||||
**Type:** Static
|
||||
**Prefix:** `:bm`
|
||||
**Package:** `owlry-plugin-bookmarks`
|
||||
|
||||
Browser bookmarks from Firefox and Chromium-based browsers.
|
||||
|
||||
**Supported browsers:**
|
||||
- Firefox (reads places.sqlite)
|
||||
- Google Chrome
|
||||
- Brave
|
||||
- Microsoft Edge
|
||||
- Vivaldi
|
||||
- Chromium
|
||||
|
||||
---
|
||||
|
||||
### owlry-plugin-websearch
|
||||
|
||||
**Type:** Dynamic
|
||||
**Prefix:** `:web`, `?`, `web `
|
||||
**Package:** `owlry-plugin-websearch`
|
||||
|
||||
Web search with configurable search engine.
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
? rust programming → Search for "rust programming"
|
||||
web linux tips → Search for "linux tips"
|
||||
```
|
||||
|
||||
**Configuration:**
|
||||
```toml
|
||||
[providers]
|
||||
search_engine = "duckduckgo" # or: google, bing, startpage
|
||||
# custom_search_url = "https://search.example.com/?q={}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### owlry-plugin-filesearch
|
||||
|
||||
**Type:** Dynamic
|
||||
**Prefix:** `:file`, `/`, `find `
|
||||
**Package:** `owlry-plugin-filesearch`
|
||||
**Dependencies:** `fd` (recommended) or `mlocate`
|
||||
|
||||
Real-time file search.
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
/ .bashrc → Find files matching ".bashrc"
|
||||
find config → Find files matching "config"
|
||||
```
|
||||
|
||||
**Configuration:**
|
||||
```toml
|
||||
[providers]
|
||||
file_search_max_results = 50
|
||||
# file_search_paths = ["/home", "/etc"] # Custom search paths
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### owlry-plugin-systemd
|
||||
|
||||
**Type:** Static (with submenu)
|
||||
**Prefix:** `:uuctl`
|
||||
**Package:** `owlry-plugin-systemd`
|
||||
**Dependencies:** `systemd`
|
||||
|
||||
User systemd services with action submenus.
|
||||
|
||||
**Features:**
|
||||
- Lists user services (`systemctl --user`)
|
||||
- Shows service status (running/stopped/failed)
|
||||
- Submenu actions: start, stop, restart, enable, disable, status
|
||||
|
||||
**Usage:**
|
||||
1. Search `:uuctl docker`
|
||||
2. Select a service
|
||||
3. Choose action from submenu
|
||||
|
||||
---
|
||||
|
||||
## Widget Plugins
|
||||
|
||||
### owlry-plugin-weather
|
||||
|
||||
**Type:** Widget (Static)
|
||||
**Package:** `owlry-plugin-weather`
|
||||
|
||||
Current weather displayed at the top of results.
|
||||
|
||||
**Supported APIs:**
|
||||
- wttr.in (default, no API key required)
|
||||
- OpenWeatherMap (requires API key)
|
||||
- Open-Meteo (no API key required)
|
||||
|
||||
**Note:** Weather configuration is currently embedded in the plugin. Future versions will support runtime configuration.
|
||||
|
||||
**Features:**
|
||||
- Temperature, condition, humidity, wind speed
|
||||
- Weather icons from Weather Icons font
|
||||
- 15-minute cache
|
||||
- Click to open detailed forecast
|
||||
|
||||
---
|
||||
|
||||
### owlry-plugin-media
|
||||
|
||||
**Type:** Widget (Static)
|
||||
**Package:** `owlry-plugin-media`
|
||||
|
||||
MPRIS media player controls.
|
||||
|
||||
**Features:**
|
||||
- Shows currently playing track
|
||||
- Artist, title, album art
|
||||
- Play/pause, next, previous controls
|
||||
- Works with Spotify, Firefox, VLC, etc.
|
||||
|
||||
---
|
||||
|
||||
### owlry-plugin-pomodoro
|
||||
|
||||
**Type:** Widget (Static)
|
||||
**Package:** `owlry-plugin-pomodoro`
|
||||
|
||||
Pomodoro timer with work/break cycles.
|
||||
|
||||
**Features:**
|
||||
- Configurable work session duration
|
||||
- Configurable break duration
|
||||
- Session counter
|
||||
- Desktop notifications on phase completion
|
||||
- Persistent state across sessions
|
||||
|
||||
**Controls:**
|
||||
- Start/Pause timer
|
||||
- Skip to next phase
|
||||
- Reset timer and sessions
|
||||
|
||||
---
|
||||
|
||||
## Bundle Packages
|
||||
|
||||
For convenience, plugins are available in bundle meta-packages:
|
||||
|
||||
| Bundle | Plugins |
|
||||
|--------|---------|
|
||||
| `owlry-meta-essentials` | calculator, system, ssh, scripts, bookmarks |
|
||||
| `owlry-meta-widgets` | weather, media, pomodoro |
|
||||
| `owlry-meta-tools` | clipboard, emoji, websearch, filesearch, systemd |
|
||||
| `owlry-meta-full` | All of the above |
|
||||
|
||||
```bash
|
||||
# Install everything
|
||||
yay -S owlry-meta-full
|
||||
|
||||
# Or pick a bundle
|
||||
yay -S owlry-meta-essentials owlry-meta-widgets
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Runtime Packages
|
||||
|
||||
For custom user plugins written in Lua or Rune:
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `owlry-lua` | Lua 5.4 runtime for user plugins |
|
||||
| `owlry-rune` | Rune runtime for user plugins |
|
||||
|
||||
User plugins are placed in `~/.config/owlry/plugins/`.
|
||||
|
||||
See [PLUGIN_DEVELOPMENT.md](PLUGIN_DEVELOPMENT.md) for creating custom plugins.
|
||||
@@ -1,571 +0,0 @@
|
||||
# Plugin Development Guide
|
||||
|
||||
This guide covers creating plugins for Owlry. There are three ways to extend Owlry:
|
||||
|
||||
1. **Native plugins** (Rust) — Best performance, ABI-stable interface
|
||||
2. **Lua plugins** — Easy scripting, requires `owlry-lua` runtime
|
||||
3. **Rune plugins** — Safe scripting with Rust-like syntax, requires `owlry-rune` runtime
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Native Plugin (Rust)
|
||||
|
||||
```bash
|
||||
# Create a new plugin crate
|
||||
cargo new --lib owlry-plugin-myplugin
|
||||
cd owlry-plugin-myplugin
|
||||
```
|
||||
|
||||
Edit `Cargo.toml`:
|
||||
```toml
|
||||
[package]
|
||||
name = "owlry-plugin-myplugin"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
owlry-plugin-api = { git = "https://somegit.dev/Owlibou/owlry" }
|
||||
abi_stable = "0.11"
|
||||
```
|
||||
|
||||
Edit `src/lib.rs`:
|
||||
```rust
|
||||
use abi_stable::std_types::{ROption, RStr, RString, RVec};
|
||||
use owlry_plugin_api::{
|
||||
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo,
|
||||
ProviderKind, ProviderPosition, API_VERSION,
|
||||
};
|
||||
|
||||
extern "C" fn plugin_info() -> PluginInfo {
|
||||
PluginInfo {
|
||||
id: RString::from("myplugin"),
|
||||
name: RString::from("My Plugin"),
|
||||
version: RString::from(env!("CARGO_PKG_VERSION")),
|
||||
description: RString::from("A custom plugin"),
|
||||
api_version: API_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
|
||||
vec![ProviderInfo {
|
||||
id: RString::from("myplugin"),
|
||||
name: RString::from("My Plugin"),
|
||||
prefix: ROption::RSome(RString::from(":my")),
|
||||
icon: RString::from("application-x-executable"),
|
||||
provider_type: ProviderKind::Static,
|
||||
type_id: RString::from("myplugin"),
|
||||
position: ProviderPosition::Normal,
|
||||
priority: 0, // Use frecency-based ordering
|
||||
}].into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
|
||||
ProviderHandle::null()
|
||||
}
|
||||
|
||||
extern "C" fn provider_refresh(_handle: ProviderHandle) -> RVec<PluginItem> {
|
||||
vec![
|
||||
PluginItem::new("item-1", "Hello World", "echo 'Hello!'")
|
||||
.with_description("A greeting")
|
||||
.with_icon("face-smile"),
|
||||
].into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec<PluginItem> {
|
||||
RVec::new()
|
||||
}
|
||||
|
||||
extern "C" fn provider_drop(_handle: ProviderHandle) {}
|
||||
|
||||
owlry_plugin! {
|
||||
info: plugin_info,
|
||||
providers: plugin_providers,
|
||||
init: provider_init,
|
||||
refresh: provider_refresh,
|
||||
query: provider_query,
|
||||
drop: provider_drop,
|
||||
}
|
||||
```
|
||||
|
||||
Build and install:
|
||||
```bash
|
||||
cargo build --release
|
||||
sudo cp target/release/libowlry_plugin_myplugin.so /usr/lib/owlry/plugins/
|
||||
```
|
||||
|
||||
### Lua Plugin
|
||||
|
||||
```bash
|
||||
# Requires owlry-lua runtime
|
||||
yay -S owlry-lua
|
||||
|
||||
# Create plugin directory
|
||||
mkdir -p ~/.config/owlry/plugins/my-lua-plugin
|
||||
```
|
||||
|
||||
Create `~/.config/owlry/plugins/my-lua-plugin/plugin.toml`:
|
||||
```toml
|
||||
[plugin]
|
||||
id = "my-lua-plugin"
|
||||
name = "My Lua Plugin"
|
||||
version = "0.1.0"
|
||||
description = "A custom Lua plugin"
|
||||
entry_point = "init.lua"
|
||||
|
||||
[[providers]]
|
||||
id = "myluaprovider"
|
||||
name = "My Lua Provider"
|
||||
prefix = ":mylua"
|
||||
icon = "application-x-executable"
|
||||
type = "static"
|
||||
type_id = "mylua"
|
||||
```
|
||||
|
||||
Create `~/.config/owlry/plugins/my-lua-plugin/init.lua`:
|
||||
```lua
|
||||
local owlry = require("owlry")
|
||||
|
||||
-- Called once at startup for static providers
|
||||
function refresh()
|
||||
return {
|
||||
owlry.item("item-1", "Hello from Lua", "echo 'Hello Lua!'")
|
||||
:description("A Lua greeting")
|
||||
:icon("face-smile"),
|
||||
}
|
||||
end
|
||||
|
||||
-- Called per-keystroke for dynamic providers
|
||||
function query(q)
|
||||
return {}
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Native Plugin API
|
||||
|
||||
### Plugin VTable
|
||||
|
||||
Every native plugin must export a function that returns a vtable:
|
||||
|
||||
```rust
|
||||
#[repr(C)]
|
||||
pub struct PluginVTable {
|
||||
pub info: extern "C" fn() -> PluginInfo,
|
||||
pub providers: extern "C" fn() -> RVec<ProviderInfo>,
|
||||
pub provider_init: extern "C" fn(provider_id: RStr<'_>) -> ProviderHandle,
|
||||
pub provider_refresh: extern "C" fn(handle: ProviderHandle) -> RVec<PluginItem>,
|
||||
pub provider_query: extern "C" fn(handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem>,
|
||||
pub provider_drop: extern "C" fn(handle: ProviderHandle),
|
||||
}
|
||||
```
|
||||
|
||||
Use the `owlry_plugin!` macro to generate the export:
|
||||
|
||||
```rust
|
||||
owlry_plugin! {
|
||||
info: my_info_fn,
|
||||
providers: my_providers_fn,
|
||||
init: my_init_fn,
|
||||
refresh: my_refresh_fn,
|
||||
query: my_query_fn,
|
||||
drop: my_drop_fn,
|
||||
}
|
||||
```
|
||||
|
||||
### PluginInfo
|
||||
|
||||
```rust
|
||||
pub struct PluginInfo {
|
||||
pub id: RString, // Unique ID (e.g., "calculator")
|
||||
pub name: RString, // Display name
|
||||
pub version: RString, // Semantic version
|
||||
pub description: RString, // Short description
|
||||
pub api_version: u32, // Must match API_VERSION
|
||||
}
|
||||
```
|
||||
|
||||
### ProviderInfo
|
||||
|
||||
```rust
|
||||
pub struct ProviderInfo {
|
||||
pub id: RString, // Provider ID within plugin
|
||||
pub name: RString, // Display name
|
||||
pub prefix: ROption<RString>, // Activation prefix (e.g., ":calc")
|
||||
pub icon: RString, // Default icon name
|
||||
pub provider_type: ProviderKind, // Static or Dynamic
|
||||
pub type_id: RString, // Short ID for badges
|
||||
pub position: ProviderPosition, // Normal or Widget
|
||||
pub priority: i32, // Result ordering (higher = first)
|
||||
}
|
||||
|
||||
pub enum ProviderKind {
|
||||
Static, // Items loaded at startup via refresh()
|
||||
Dynamic, // Items computed per-query via query()
|
||||
}
|
||||
|
||||
pub enum ProviderPosition {
|
||||
Normal, // Standard results (sorted by score/frecency)
|
||||
Widget, // Displayed at top when query is empty
|
||||
}
|
||||
```
|
||||
|
||||
### PluginItem
|
||||
|
||||
```rust
|
||||
pub struct PluginItem {
|
||||
pub id: RString, // Unique item ID
|
||||
pub name: RString, // Display name
|
||||
pub description: ROption<RString>, // Optional description
|
||||
pub icon: ROption<RString>, // Optional icon
|
||||
pub command: RString, // Command to execute
|
||||
pub terminal: bool, // Run in terminal?
|
||||
pub keywords: RVec<RString>, // Search keywords
|
||||
pub score_boost: i32, // Frecency boost
|
||||
}
|
||||
|
||||
// Builder pattern
|
||||
let item = PluginItem::new("id", "Name", "command")
|
||||
.with_description("Description")
|
||||
.with_icon("icon-name")
|
||||
.with_terminal(true)
|
||||
.with_keywords(vec!["tag1".to_string(), "tag2".to_string()])
|
||||
.with_score_boost(100);
|
||||
```
|
||||
|
||||
### ProviderHandle
|
||||
|
||||
For stateful providers, use `ProviderHandle` to store state:
|
||||
|
||||
```rust
|
||||
struct MyState {
|
||||
items: Vec<PluginItem>,
|
||||
cache: HashMap<String, String>,
|
||||
}
|
||||
|
||||
extern "C" fn provider_init(_: RStr<'_>) -> ProviderHandle {
|
||||
let state = Box::new(MyState {
|
||||
items: Vec::new(),
|
||||
cache: HashMap::new(),
|
||||
});
|
||||
ProviderHandle::from_box(state)
|
||||
}
|
||||
|
||||
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
|
||||
if handle.ptr.is_null() {
|
||||
return RVec::new();
|
||||
}
|
||||
|
||||
let state = unsafe { &mut *(handle.ptr as *mut MyState) };
|
||||
state.items = load_items();
|
||||
state.items.clone().into()
|
||||
}
|
||||
|
||||
extern "C" fn provider_drop(handle: ProviderHandle) {
|
||||
if !handle.ptr.is_null() {
|
||||
unsafe { handle.drop_as::<MyState>(); }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Host API
|
||||
|
||||
Plugins can use host-provided functions:
|
||||
|
||||
```rust
|
||||
use owlry_plugin_api::{notify, notify_with_icon, log_info, log_warn, log_error};
|
||||
|
||||
// Send notifications
|
||||
notify("Title", "Body text");
|
||||
notify_with_icon("Title", "Body", "dialog-information");
|
||||
|
||||
// Logging
|
||||
log_info("Plugin loaded successfully");
|
||||
log_warn("Cache miss, fetching data");
|
||||
log_error("Failed to connect to API");
|
||||
```
|
||||
|
||||
### Submenu Support
|
||||
|
||||
Plugins can provide submenus for detailed actions:
|
||||
|
||||
```rust
|
||||
// Return an item that opens a submenu
|
||||
PluginItem::new(
|
||||
"service-docker",
|
||||
"Docker",
|
||||
"SUBMENU:systemd:docker.service", // Special command format
|
||||
)
|
||||
|
||||
// Handle submenu query (query starts with "?SUBMENU:")
|
||||
extern "C" fn provider_query(handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem> {
|
||||
let q = query.as_str();
|
||||
|
||||
if let Some(data) = q.strip_prefix("?SUBMENU:") {
|
||||
// Return submenu actions
|
||||
return vec![
|
||||
PluginItem::new("start", "Start", format!("systemctl start {}", data)),
|
||||
PluginItem::new("stop", "Stop", format!("systemctl stop {}", data)),
|
||||
].into();
|
||||
}
|
||||
|
||||
RVec::new()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lua Plugin API
|
||||
|
||||
### Plugin Manifest (plugin.toml)
|
||||
|
||||
```toml
|
||||
[plugin]
|
||||
id = "my-plugin"
|
||||
name = "My Plugin"
|
||||
version = "1.0.0"
|
||||
description = "Plugin description"
|
||||
entry_point = "init.lua"
|
||||
owlry_version = ">=0.4.0" # Optional version constraint
|
||||
|
||||
[permissions]
|
||||
fs = ["read"] # File system access
|
||||
http = true # HTTP requests
|
||||
process = true # Spawn processes
|
||||
|
||||
[[providers]]
|
||||
id = "provider1"
|
||||
name = "Provider Name"
|
||||
prefix = ":prefix"
|
||||
icon = "icon-name"
|
||||
type = "static" # or "dynamic"
|
||||
type_id = "shortid"
|
||||
```
|
||||
|
||||
### Lua API
|
||||
|
||||
```lua
|
||||
local owlry = require("owlry")
|
||||
|
||||
-- Create items
|
||||
local item = owlry.item(id, name, command)
|
||||
:description("Description")
|
||||
:icon("icon-name")
|
||||
:terminal(false)
|
||||
:keywords({"tag1", "tag2"})
|
||||
|
||||
-- Notifications
|
||||
owlry.notify("Title", "Body")
|
||||
owlry.notify_icon("Title", "Body", "icon-name")
|
||||
|
||||
-- Logging
|
||||
owlry.log.info("Message")
|
||||
owlry.log.warn("Warning")
|
||||
owlry.log.error("Error")
|
||||
|
||||
-- File operations (requires fs permission)
|
||||
local content = owlry.fs.read("/path/to/file")
|
||||
local files = owlry.fs.list("/path/to/dir")
|
||||
local exists = owlry.fs.exists("/path")
|
||||
|
||||
-- HTTP requests (requires http permission)
|
||||
local response = owlry.http.get("https://api.example.com/data")
|
||||
local json = owlry.json.decode(response)
|
||||
|
||||
-- Process execution (requires process permission)
|
||||
local output = owlry.process.run("ls", {"-la"})
|
||||
|
||||
-- Cache (persistent across sessions)
|
||||
owlry.cache.set("key", value, ttl_seconds)
|
||||
local value = owlry.cache.get("key")
|
||||
```
|
||||
|
||||
### Provider Functions
|
||||
|
||||
```lua
|
||||
-- Static provider: called once at startup
|
||||
function refresh()
|
||||
return {
|
||||
owlry.item("id1", "Item 1", "command1"),
|
||||
owlry.item("id2", "Item 2", "command2"),
|
||||
}
|
||||
end
|
||||
|
||||
-- Dynamic provider: called on each keystroke
|
||||
function query(q)
|
||||
if q == "" then
|
||||
return {}
|
||||
end
|
||||
|
||||
return {
|
||||
owlry.item("result", "Result for: " .. q, "echo " .. q),
|
||||
}
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rune Plugin API
|
||||
|
||||
Rune plugins use a Rust-like syntax with memory safety.
|
||||
|
||||
### Plugin Manifest
|
||||
|
||||
```toml
|
||||
[plugin]
|
||||
id = "my-rune-plugin"
|
||||
name = "My Rune Plugin"
|
||||
version = "1.0.0"
|
||||
entry_point = "main.rn"
|
||||
|
||||
[[providers]]
|
||||
id = "runeprovider"
|
||||
name = "Rune Provider"
|
||||
type = "static"
|
||||
```
|
||||
|
||||
### Rune API
|
||||
|
||||
```rune
|
||||
use owlry::{Item, log, notify};
|
||||
|
||||
pub fn refresh() {
|
||||
let items = [];
|
||||
|
||||
items.push(Item::new("id", "Name", "command")
|
||||
.description("Description")
|
||||
.icon("icon-name"));
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
pub fn query(q) {
|
||||
if q.is_empty() {
|
||||
return [];
|
||||
}
|
||||
|
||||
log::info(`Query: {q}`);
|
||||
|
||||
[Item::new("result", `Result: {q}`, `echo {q}`)]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Performance
|
||||
|
||||
1. **Static providers**: Do expensive work in `refresh()`, not `items()`
|
||||
2. **Dynamic providers**: Keep `query()` fast (<50ms)
|
||||
3. **Cache data**: Use persistent cache for API responses
|
||||
4. **Lazy loading**: Don't load all items if only a few are needed
|
||||
|
||||
### Error Handling
|
||||
|
||||
```rust
|
||||
// Native: Return empty vec on error, log the issue
|
||||
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
|
||||
match load_data() {
|
||||
Ok(items) => items.into(),
|
||||
Err(e) => {
|
||||
log_error(&format!("Failed to load: {}", e));
|
||||
RVec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```lua
|
||||
-- Lua: Wrap in pcall for safety
|
||||
function refresh()
|
||||
local ok, result = pcall(function()
|
||||
return load_items()
|
||||
end)
|
||||
|
||||
if not ok then
|
||||
owlry.log.error("Failed: " .. result)
|
||||
return {}
|
||||
end
|
||||
|
||||
return result
|
||||
end
|
||||
```
|
||||
|
||||
### Icons
|
||||
|
||||
Use freedesktop icon names for consistency:
|
||||
- `application-x-executable` — Generic executable
|
||||
- `folder` — Directories
|
||||
- `text-x-generic` — Text files
|
||||
- `face-smile` — Emoji/reactions
|
||||
- `system-shutdown` — Power actions
|
||||
- `network-server` — SSH/network
|
||||
- `edit-paste` — Clipboard
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Build and test native plugin
|
||||
cargo build --release -p owlry-plugin-myplugin
|
||||
cargo test -p owlry-plugin-myplugin
|
||||
|
||||
# Install for testing
|
||||
sudo cp target/release/libowlry_plugin_myplugin.so /usr/lib/owlry/plugins/
|
||||
|
||||
# Test with verbose logging
|
||||
RUST_LOG=debug owlry
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Publishing to AUR
|
||||
|
||||
### PKGBUILD Template
|
||||
|
||||
```bash
|
||||
# Maintainer: Your Name <email@example.com>
|
||||
pkgname=owlry-plugin-myplugin
|
||||
pkgver=0.1.0
|
||||
pkgrel=1
|
||||
pkgdesc="My custom Owlry plugin"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/you/owlry-plugin-myplugin"
|
||||
license=('GPL-3.0-or-later')
|
||||
depends=('owlry')
|
||||
makedepends=('rust' 'cargo')
|
||||
source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz")
|
||||
sha256sums=('...')
|
||||
|
||||
build() {
|
||||
cd "$pkgname-$pkgver"
|
||||
cargo build --release
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$pkgname-$pkgver"
|
||||
install -Dm755 "target/release/lib${pkgname//-/_}.so" \
|
||||
"$pkgdir/usr/lib/owlry/plugins/lib${pkgname//-/_}.so"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example Plugins
|
||||
|
||||
The owlry repository includes 13 native plugins as reference implementations:
|
||||
|
||||
| Plugin | Type | Highlights |
|
||||
|--------|------|------------|
|
||||
| `owlry-plugin-calculator` | Dynamic | Math parsing, expression evaluation |
|
||||
| `owlry-plugin-weather` | Static/Widget | HTTP API, JSON parsing, caching |
|
||||
| `owlry-plugin-systemd` | Static | Submenu actions, service management |
|
||||
| `owlry-plugin-pomodoro` | Static/Widget | State persistence, notifications |
|
||||
| `owlry-plugin-clipboard` | Static | External process integration |
|
||||
|
||||
Browse the source at `crates/owlry-plugin-*/` for implementation details.
|
||||
191
justfile
191
justfile
@@ -49,15 +49,7 @@ fmt:
|
||||
clean:
|
||||
cargo clean
|
||||
|
||||
# Build a specific plugin (when plugins exist)
|
||||
plugin name:
|
||||
cargo build -p owlry-plugin-{{name}} --release
|
||||
|
||||
# Build all plugins
|
||||
plugins:
|
||||
cargo build --workspace --release --exclude owlry --exclude owlry-core
|
||||
|
||||
# Install locally (core + plugins + runtimes)
|
||||
# Install locally (core + runtimes)
|
||||
install-local:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
@@ -67,38 +59,17 @@ install-local:
|
||||
cargo build -p owlry --release --no-default-features
|
||||
# Build core daemon
|
||||
cargo build -p owlry-core --release
|
||||
# Build plugins
|
||||
cargo build --workspace --release --exclude owlry --exclude owlry-core
|
||||
# Build runtimes
|
||||
cargo build -p owlry-lua -p owlry-rune --release
|
||||
|
||||
echo "Creating directories..."
|
||||
sudo mkdir -p /usr/lib/owlry/plugins
|
||||
sudo mkdir -p /usr/lib/owlry/runtimes
|
||||
|
||||
echo "Cleaning up stale files..."
|
||||
# Remove runtime files that may have ended up in plugins dir (from old installs)
|
||||
sudo rm -f /usr/lib/owlry/plugins/libowlry_lua.so /usr/lib/owlry/plugins/libowlry_rune.so
|
||||
# Remove old short-named plugin files (from old AUR packages before naming standardization)
|
||||
sudo rm -f /usr/lib/owlry/plugins/libbookmarks.so /usr/lib/owlry/plugins/libcalculator.so \
|
||||
/usr/lib/owlry/plugins/libclipboard.so /usr/lib/owlry/plugins/libemoji.so \
|
||||
/usr/lib/owlry/plugins/libfilesearch.so /usr/lib/owlry/plugins/libmedia.so \
|
||||
/usr/lib/owlry/plugins/libpomodoro.so /usr/lib/owlry/plugins/libscripts.so \
|
||||
/usr/lib/owlry/plugins/libssh.so /usr/lib/owlry/plugins/libsystem.so \
|
||||
/usr/lib/owlry/plugins/libsystemd.so /usr/lib/owlry/plugins/libweather.so \
|
||||
/usr/lib/owlry/plugins/libwebsearch.so
|
||||
|
||||
echo "Installing binaries..."
|
||||
sudo install -Dm755 target/release/owlry /usr/bin/owlry
|
||||
sudo install -Dm755 target/release/owlry-core /usr/bin/owlry-core
|
||||
|
||||
echo "Installing plugins..."
|
||||
for plugin in target/release/libowlry_plugin_*.so; do
|
||||
if [ -f "$plugin" ]; then
|
||||
name=$(basename "$plugin")
|
||||
sudo install -Dm755 "$plugin" "/usr/lib/owlry/plugins/$name"
|
||||
echo " → $name"
|
||||
fi
|
||||
done
|
||||
|
||||
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
|
||||
@@ -123,13 +94,14 @@ install-local:
|
||||
echo "Installation complete!"
|
||||
echo " - /usr/bin/owlry (UI)"
|
||||
echo " - /usr/bin/owlry-core (daemon)"
|
||||
echo " - $(ls /usr/lib/owlry/plugins/*.so 2>/dev/null | wc -l) plugins"
|
||||
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."
|
||||
|
||||
# === Release Management ===
|
||||
|
||||
@@ -157,7 +129,7 @@ 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-plugin-calculator 0.2.0)
|
||||
# Bump a specific crate version (usage: just bump-crate owlry-core 0.2.0)
|
||||
bump-crate crate new_version:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
@@ -178,23 +150,6 @@ bump-crate crate new_version:
|
||||
git commit -m "chore({{crate}}): bump version to {{new_version}}"
|
||||
echo "{{crate}} bumped to {{new_version}}"
|
||||
|
||||
# Bump all plugins to same version (usage: just bump-plugins 0.2.0)
|
||||
bump-plugins new_version:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
for toml in crates/owlry-plugin-*/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/owlry-plugin-*/Cargo.toml Cargo.lock
|
||||
git commit -m "chore(plugins): bump all plugins to {{new_version}}"
|
||||
echo "All plugins bumped to {{new_version}}"
|
||||
|
||||
# Bump meta-packages (no crate, just AUR version)
|
||||
bump-meta new_version:
|
||||
#!/usr/bin/env bash
|
||||
@@ -210,28 +165,11 @@ bump-meta new_version:
|
||||
done
|
||||
echo "Meta-packages bumped to {{new_version}}"
|
||||
|
||||
# Bump all crates (core + plugins + runtimes) to same version
|
||||
# Bump all crates (core UI + daemon + plugin-api + runtimes) to same version
|
||||
bump-all new_version:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# Bump core (UI)
|
||||
toml="crates/owlry/Cargo.toml"
|
||||
old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||
if [ "$old" != "{{new_version}}" ]; then
|
||||
echo "Bumping owlry from $old to {{new_version}}"
|
||||
sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml"
|
||||
fi
|
||||
# Bump core daemon
|
||||
toml="crates/owlry-core/Cargo.toml"
|
||||
if [ -f "$toml" ]; then
|
||||
old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||
if [ "$old" != "{{new_version}}" ]; then
|
||||
echo "Bumping owlry-core from $old to {{new_version}}"
|
||||
sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml"
|
||||
fi
|
||||
fi
|
||||
# Bump plugins (including plugin-api)
|
||||
for toml in crates/owlry-plugin-*/Cargo.toml; do
|
||||
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
|
||||
@@ -239,17 +177,6 @@ bump-all new_version:
|
||||
sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml"
|
||||
fi
|
||||
done
|
||||
# Bump runtimes
|
||||
for toml in crates/owlry-lua/Cargo.toml crates/owlry-rune/Cargo.toml; do
|
||||
if [ -f "$toml" ]; then
|
||||
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
|
||||
fi
|
||||
done
|
||||
cargo check --workspace
|
||||
git add crates/*/Cargo.toml Cargo.lock
|
||||
git commit -m "chore: bump all crates to {{new_version}}"
|
||||
@@ -283,7 +210,7 @@ tag:
|
||||
git push origin "v{{version}}"
|
||||
echo "Tag v{{version}} pushed"
|
||||
|
||||
# Update AUR package (core)
|
||||
# Update AUR package (core UI)
|
||||
aur-update:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
@@ -311,7 +238,7 @@ aur-update:
|
||||
echo "AUR package updated. Review changes above."
|
||||
echo "Run 'just aur-publish' to commit and push."
|
||||
|
||||
# Publish AUR package (core)
|
||||
# Publish AUR package (core UI)
|
||||
aur-publish:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
@@ -323,7 +250,7 @@ aur-publish:
|
||||
|
||||
echo "AUR package v{{version}} published!"
|
||||
|
||||
# Test AUR package build locally (core)
|
||||
# Test AUR package build locally (core UI)
|
||||
aur-test:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
@@ -338,7 +265,7 @@ aur-test:
|
||||
|
||||
# === AUR Package Management (individual packages) ===
|
||||
|
||||
# Update a specific AUR package (usage: just aur-update-pkg owlry-plugin-calculator)
|
||||
# Update a specific AUR package (usage: just aur-update-pkg owlry-core)
|
||||
aur-update-pkg pkg:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
@@ -351,7 +278,7 @@ aur-update-pkg pkg:
|
||||
|
||||
url="https://somegit.dev/Owlibou/owlry"
|
||||
|
||||
# Determine crate version (unified versioning: all crates share same version)
|
||||
# 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
|
||||
@@ -376,7 +303,7 @@ aur-update-pkg pkg:
|
||||
sed -i "s/^pkgver=.*/pkgver=$crate_ver/" PKGBUILD
|
||||
sed -i 's/^pkgrel=.*/pkgrel=1/' PKGBUILD
|
||||
|
||||
# Update checksums (unified versioning: all packages use same version)
|
||||
# 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)
|
||||
@@ -424,38 +351,6 @@ aur-test-pkg pkg:
|
||||
echo "Package built successfully!"
|
||||
ls -lh *.pkg.tar.zst
|
||||
|
||||
# Update all plugin AUR packages
|
||||
aur-update-plugins:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
for dir in aur/owlry-plugin-*/; do
|
||||
pkg=$(basename "$dir")
|
||||
echo "=== Updating $pkg ==="
|
||||
just aur-update-pkg "$pkg"
|
||||
echo ""
|
||||
done
|
||||
|
||||
# Publish all plugin AUR packages
|
||||
aur-publish-plugins:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
for dir in aur/owlry-plugin-*/; do
|
||||
pkg=$(basename "$dir")
|
||||
echo "=== Publishing $pkg ==="
|
||||
just aur-publish-pkg "$pkg"
|
||||
echo ""
|
||||
done
|
||||
|
||||
# Publish all meta-packages
|
||||
aur-publish-meta:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
for pkg in owlry-meta-essentials owlry-meta-tools owlry-meta-widgets owlry-meta-full; do
|
||||
echo "=== Publishing $pkg ==="
|
||||
just aur-publish-pkg "$pkg"
|
||||
done
|
||||
echo "All meta-packages published!"
|
||||
|
||||
# List all AUR packages with their versions
|
||||
aur-status:
|
||||
#!/usr/bin/env bash
|
||||
@@ -473,19 +368,15 @@ aur-status:
|
||||
fi
|
||||
done
|
||||
|
||||
# Update ALL AUR packages (core + plugins + runtimes + meta)
|
||||
# Update ALL AUR packages (core + daemon + runtimes + meta)
|
||||
aur-update-all:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
echo "=== Updating core ==="
|
||||
echo "=== Updating core UI ==="
|
||||
just aur-update
|
||||
echo ""
|
||||
echo "=== Updating plugins ==="
|
||||
for dir in aur/owlry-plugin-*/; do
|
||||
pkg=$(basename "$dir")
|
||||
echo "--- $pkg ---"
|
||||
just aur-update-pkg "$pkg"
|
||||
done
|
||||
echo "=== Updating core daemon ==="
|
||||
just aur-update-pkg owlry-core
|
||||
echo ""
|
||||
echo "=== Updating runtimes ==="
|
||||
just aur-update-pkg owlry-lua
|
||||
@@ -503,15 +394,11 @@ aur-update-all:
|
||||
aur-publish-all:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
echo "=== Publishing core ==="
|
||||
echo "=== Publishing core UI ==="
|
||||
just aur-publish
|
||||
echo ""
|
||||
echo "=== Publishing plugins ==="
|
||||
for dir in aur/owlry-plugin-*/; do
|
||||
pkg=$(basename "$dir")
|
||||
echo "--- $pkg ---"
|
||||
just aur-publish-pkg "$pkg"
|
||||
done
|
||||
echo "=== Publishing core daemon ==="
|
||||
just aur-publish-pkg owlry-core
|
||||
echo ""
|
||||
echo "=== Publishing runtimes ==="
|
||||
just aur-publish-pkg owlry-lua
|
||||
@@ -546,39 +433,3 @@ release-core new_version: (bump new_version)
|
||||
echo ""
|
||||
echo "Core release v{{new_version}} prepared!"
|
||||
echo "Review AUR changes, then run 'just aur-publish'"
|
||||
|
||||
# Full release workflow for everything (core + plugins + runtimes)
|
||||
# Usage: just release-all 0.5.0 0.3.0
|
||||
# First arg is core version, second is plugins/runtimes version
|
||||
release-all core_version plugin_version:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "=== Bumping versions ==="
|
||||
just bump {{core_version}}
|
||||
just bump-all {{plugin_version}}
|
||||
|
||||
echo ""
|
||||
echo "=== Pushing to origin ==="
|
||||
git push
|
||||
|
||||
echo ""
|
||||
echo "=== Creating tag ==="
|
||||
just tag
|
||||
|
||||
echo "Waiting for tag to propagate..."
|
||||
sleep 2
|
||||
|
||||
echo ""
|
||||
echo "=== Updating all AUR packages ==="
|
||||
just aur-update-all
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "Release prepared!"
|
||||
echo " Core: v{{core_version}}"
|
||||
echo " Plugins/Runtimes: v{{plugin_version}}"
|
||||
echo ""
|
||||
echo "Review changes with 'just aur-status'"
|
||||
echo "Then publish with 'just aur-publish-all'"
|
||||
echo "=========================================="
|
||||
|
||||
Reference in New Issue
Block a user