diff --git a/Cargo.lock b/Cargo.lock index bff6297..91ebb4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 13cb96e..c961699 100644 --- a/Cargo.toml +++ b/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 diff --git a/crates/owlry-plugin-bookmarks/Cargo.toml b/crates/owlry-plugin-bookmarks/Cargo.toml deleted file mode 100644 index 65f961a..0000000 --- a/crates/owlry-plugin-bookmarks/Cargo.toml +++ /dev/null @@ -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"] } diff --git a/crates/owlry-plugin-bookmarks/src/lib.rs b/crates/owlry-plugin-bookmarks/src/lib.rs deleted file mode 100644 index 3eb5f3c..0000000 --- a/crates/owlry-plugin-bookmarks/src/lib.rs +++ /dev/null @@ -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, - /// Flag to prevent concurrent background loads - loading: Arc, -} - -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 { - dirs::cache_dir().map(|d| d.join("owlry/favicons")) - } - - /// Ensure the favicon cache directory exists - fn ensure_favicon_cache_dir() -> Option { - 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 { - dirs::cache_dir().map(|d| d.join("owlry/bookmarks.json")) - } - - /// Load cached bookmarks from disk (fast) - fn load_cached_bookmarks() -> Vec { - 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, - icon: String, - } - - let cached: Vec = 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, - icon: String, - } - - let cached: Vec = items - .iter() - .map(|item| { - let desc: Option = 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 { - 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 { - 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 { - 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) { - 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) { - 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) { - 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)> { - // 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 { - // 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> = 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, -} - -#[derive(Debug, Deserialize)] -struct ChromeBookmarkRoots { - bookmark_bar: Option, - other: Option, - synced: Option, -} - -#[derive(Debug, Deserialize)] -struct ChromeBookmarkNode { - name: Option, - url: Option, - #[serde(rename = "type")] - node_type: Option, - children: Option>, -} - -// ============================================================================ -// 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 { - 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 { - if handle.ptr.is_null() { - return RVec::new(); - } - - // SAFETY: We created this handle from Box - 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 { - // 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 - unsafe { - handle.drop_as::(); - } - } -} - -// 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("'\\''")); - } -} diff --git a/crates/owlry-plugin-calculator/Cargo.toml b/crates/owlry-plugin-calculator/Cargo.toml deleted file mode 100644 index cf2086d..0000000 --- a/crates/owlry-plugin-calculator/Cargo.toml +++ /dev/null @@ -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" diff --git a/crates/owlry-plugin-calculator/src/lib.rs b/crates/owlry-plugin-calculator/src/lib.rs deleted file mode 100644 index dc9ab19..0000000 --- a/crates/owlry-plugin-calculator/src/lib.rs +++ /dev/null @@ -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 { - 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 { - // Dynamic provider - refresh does nothing - RVec::new() -} - -extern "C" fn provider_query(_handle: ProviderHandle, query: RStr<'_>) -> RVec { - 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 - unsafe { - handle.drop_as::(); - } - } -} - -// 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 { - 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()); - } -} diff --git a/crates/owlry-plugin-clipboard/Cargo.toml b/crates/owlry-plugin-clipboard/Cargo.toml deleted file mode 100644 index a8e449c..0000000 --- a/crates/owlry-plugin-clipboard/Cargo.toml +++ /dev/null @@ -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" diff --git a/crates/owlry-plugin-clipboard/src/lib.rs b/crates/owlry-plugin-clipboard/src/lib.rs deleted file mode 100644 index 600e67b..0000000 --- a/crates/owlry-plugin-clipboard/src/lib.rs +++ /dev/null @@ -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, - 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 { - 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 { - if handle.ptr.is_null() { - return RVec::new(); - } - - // SAFETY: We created this handle from Box - 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 { - // 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 - unsafe { - handle.drop_as::(); - } - } -} - -// 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(); - } -} diff --git a/crates/owlry-plugin-emoji/Cargo.toml b/crates/owlry-plugin-emoji/Cargo.toml deleted file mode 100644 index f7b957d..0000000 --- a/crates/owlry-plugin-emoji/Cargo.toml +++ /dev/null @@ -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" diff --git a/crates/owlry-plugin-emoji/src/lib.rs b/crates/owlry-plugin-emoji/src/lib.rs deleted file mode 100644 index 9e08045..0000000 --- a/crates/owlry-plugin-emoji/src/lib.rs +++ /dev/null @@ -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, -} - -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 { - 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 { - if handle.ptr.is_null() { - return RVec::new(); - } - - // SAFETY: We created this handle from Box - 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 { - // 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 - unsafe { - handle.drop_as::(); - } - } -} - -// 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()); - } -} diff --git a/crates/owlry-plugin-filesearch/Cargo.toml b/crates/owlry-plugin-filesearch/Cargo.toml deleted file mode 100644 index e59c18c..0000000 --- a/crates/owlry-plugin-filesearch/Cargo.toml +++ /dev/null @@ -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" diff --git a/crates/owlry-plugin-filesearch/src/lib.rs b/crates/owlry-plugin-filesearch/src/lib.rs deleted file mode 100644 index 9eca26a..0000000 --- a/crates/owlry-plugin-filesearch/src/lib.rs +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - // Dynamic provider - refresh does nothing - RVec::new() -} - -extern "C" fn provider_query(handle: ProviderHandle, query: RStr<'_>) -> RVec { - if handle.ptr.is_null() { - return RVec::new(); - } - - // SAFETY: We created this handle from Box - 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 - unsafe { - handle.drop_as::(); - } - } -} - -// 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()); - } -} diff --git a/crates/owlry-plugin-media/Cargo.toml b/crates/owlry-plugin-media/Cargo.toml deleted file mode 100644 index 9bb3c4b..0000000 --- a/crates/owlry-plugin-media/Cargo.toml +++ /dev/null @@ -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" diff --git a/crates/owlry-plugin-media/src/lib.rs b/crates/owlry-plugin-media/src/lib.rs deleted file mode 100644 index 0b064c2..0000000 --- a/crates/owlry-plugin-media/src/lib.rs +++ /dev/null @@ -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, - /// Current player name for submenu actions - current_player: Option, - /// 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 { - 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 { - 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 { - 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 { - 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 { - 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 - - // 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 { - 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 { - if handle.ptr.is_null() { - return RVec::new(); - } - - // SAFETY: We created this handle from Box - 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 { - 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 - unsafe { - handle.drop_as::(); - } - } -} - -// 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; - } -} diff --git a/crates/owlry-plugin-pomodoro/Cargo.toml b/crates/owlry-plugin-pomodoro/Cargo.toml deleted file mode 100644 index ef24bf5..0000000 --- a/crates/owlry-plugin-pomodoro/Cargo.toml +++ /dev/null @@ -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" diff --git a/crates/owlry-plugin-pomodoro/src/lib.rs b/crates/owlry-plugin-pomodoro/src/lib.rs deleted file mode 100644 index 85f4af7..0000000 --- a/crates/owlry-plugin-pomodoro/src/lib.rs +++ /dev/null @@ -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::() - { - // 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, - 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 { - dirs::data_dir().map(|d| d.join("owlry")) - } - - fn load_state() -> Option { - 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 { - 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 { - 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 { - 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 { - 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::(); - } - } -} - -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); - } -} diff --git a/crates/owlry-plugin-scripts/Cargo.toml b/crates/owlry-plugin-scripts/Cargo.toml deleted file mode 100644 index 824e206..0000000 --- a/crates/owlry-plugin-scripts/Cargo.toml +++ /dev/null @@ -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" diff --git a/crates/owlry-plugin-scripts/src/lib.rs b/crates/owlry-plugin-scripts/src/lib.rs deleted file mode 100644 index c49efa4..0000000 --- a/crates/owlry-plugin-scripts/src/lib.rs +++ /dev/null @@ -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, -} - -impl ScriptsState { - fn new() -> Self { - Self { items: Vec::new() } - } - - fn scripts_dir() -> Option { - 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 { - 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 { - 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 { - if handle.ptr.is_null() { - return RVec::new(); - } - - // SAFETY: We created this handle from Box - 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 { - // 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 - unsafe { - handle.drop_as::(); - } - } -} - -// 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")); - } -} diff --git a/crates/owlry-plugin-ssh/Cargo.toml b/crates/owlry-plugin-ssh/Cargo.toml deleted file mode 100644 index 0e3c456..0000000 --- a/crates/owlry-plugin-ssh/Cargo.toml +++ /dev/null @@ -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" diff --git a/crates/owlry-plugin-ssh/src/lib.rs b/crates/owlry-plugin-ssh/src/lib.rs deleted file mode 100644 index 01e889b..0000000 --- a/crates/owlry-plugin-ssh/src/lib.rs +++ /dev/null @@ -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, - 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 { - 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 = None; - let mut current_hostname: Option = None; - let mut current_user: Option = None; - let mut current_port: Option = 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, - user: Option, - port: Option, - ) { - // 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 { - 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 { - if handle.ptr.is_null() { - return RVec::new(); - } - - // SAFETY: We created this handle from Box - 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 { - // 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 - unsafe { - handle.drop_as::(); - } - } -} - -// 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")); - } -} diff --git a/crates/owlry-plugin-system/Cargo.toml b/crates/owlry-plugin-system/Cargo.toml deleted file mode 100644 index df26101..0000000 --- a/crates/owlry-plugin-system/Cargo.toml +++ /dev/null @@ -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" diff --git a/crates/owlry-plugin-system/src/lib.rs b/crates/owlry-plugin-system/src/lib.rs deleted file mode 100644 index f68e4f7..0000000 --- a/crates/owlry-plugin-system/src/lib.rs +++ /dev/null @@ -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, -} - -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 { - 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 { - if handle.ptr.is_null() { - return RVec::new(); - } - - // SAFETY: We created this handle from Box - 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 { - // 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 - unsafe { - handle.drop_as::(); - } - } -} - -// 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() - ); - } - } -} diff --git a/crates/owlry-plugin-systemd/Cargo.toml b/crates/owlry-plugin-systemd/Cargo.toml deleted file mode 100644 index 323af95..0000000 --- a/crates/owlry-plugin-systemd/Cargo.toml +++ /dev/null @@ -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" diff --git a/crates/owlry-plugin-systemd/src/lib.rs b/crates/owlry-plugin-systemd/src/lib.rs deleted file mode 100644 index 25b0afa..0000000 --- a/crates/owlry-plugin-systemd/src/lib.rs +++ /dev/null @@ -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, -} - -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 { - 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::>().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 { - 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 { - 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 { - if handle.ptr.is_null() { - return RVec::new(); - } - - // SAFETY: We created this handle from Box - 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 { - 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 - unsafe { - handle.drop_as::(); - } - } -} - -// 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"); - } -} diff --git a/crates/owlry-plugin-weather/Cargo.toml b/crates/owlry-plugin-weather/Cargo.toml deleted file mode 100644 index 894478d..0000000 --- a/crates/owlry-plugin-weather/Cargo.toml +++ /dev/null @@ -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" diff --git a/crates/owlry-plugin-weather/src/lib.rs b/crates/owlry-plugin-weather/src/lib.rs deleted file mode 100644 index 0f3edc7..0000000 --- a/crates/owlry-plugin-weather/src/lib.rs +++ /dev/null @@ -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 { - 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, - 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::() - { - // 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, - condition: String, - humidity: Option, - wind_speed: Option, - 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, - config: WeatherConfig, - last_fetch_epoch: u64, - cached_data: Option, -} - -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 { - dirs::data_dir().map(|d| d.join("owlry")) - } - - fn cache_path() -> Option { - Self::data_dir().map(|d| d.join("weather_cache.json")) - } - - fn load_cache() -> Option { - 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 { - 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 { - 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 { - 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 { - 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::(), - parts[1].trim().parse::(), - ) { - 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, - nearest_area: Vec, -} - -#[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, - #[serde(rename = "windspeedKmph")] - windspeed_kmph: String, -} - -#[derive(Debug, Deserialize)] -struct WttrInValue { - value: String, -} - -#[derive(Debug, Deserialize)] -struct WttrInArea { - #[serde(rename = "areaName")] - area_name: Vec, -} - -#[derive(Debug, Deserialize)] -struct OpenWeatherMapResponse { - main: OwmMain, - weather: Vec, - 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>, -} - -#[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 { - 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 { - if handle.ptr.is_null() { - return RVec::new(); - } - - // SAFETY: We created this handle from Box - 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 { - // 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 - unsafe { - handle.drop_as::(); - } - } -} - -// 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::().unwrap(), - WeatherProviderType::WttrIn - ); - assert_eq!( - "owm".parse::().unwrap(), - WeatherProviderType::OpenWeatherMap - ); - assert_eq!( - "open-meteo".parse::().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()); - } -} diff --git a/crates/owlry-plugin-websearch/Cargo.toml b/crates/owlry-plugin-websearch/Cargo.toml deleted file mode 100644 index c07b52a..0000000 --- a/crates/owlry-plugin-websearch/Cargo.toml +++ /dev/null @@ -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" diff --git a/crates/owlry-plugin-websearch/src/lib.rs b/crates/owlry-plugin-websearch/src/lib.rs deleted file mode 100644 index 66cf00f..0000000 --- a/crates/owlry-plugin-websearch/src/lib.rs +++ /dev/null @@ -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 { - 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 { - 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 { - // Dynamic provider - refresh does nothing - RVec::new() -} - -extern "C" fn provider_query(handle: ProviderHandle, query: RStr<'_>) -> RVec { - if handle.ptr.is_null() { - return RVec::new(); - } - - // SAFETY: We created this handle from Box - 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 - unsafe { - handle.drop_as::(); - } - } -} - -// 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 - } -} diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md deleted file mode 100644 index 40626c6..0000000 --- a/docs/PLUGINS.md +++ /dev/null @@ -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. diff --git a/docs/PLUGIN_DEVELOPMENT.md b/docs/PLUGIN_DEVELOPMENT.md deleted file mode 100644 index 604a827..0000000 --- a/docs/PLUGIN_DEVELOPMENT.md +++ /dev/null @@ -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 { - 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 { - 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 { - 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, - pub provider_init: extern "C" fn(provider_id: RStr<'_>) -> ProviderHandle, - pub provider_refresh: extern "C" fn(handle: ProviderHandle) -> RVec, - pub provider_query: extern "C" fn(handle: ProviderHandle, query: RStr<'_>) -> RVec, - 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, // 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, // Optional description - pub icon: ROption, // Optional icon - pub command: RString, // Command to execute - pub terminal: bool, // Run in terminal? - pub keywords: RVec, // 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, - cache: HashMap, -} - -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 { - 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::(); } - } -} -``` - -### 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 { - 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 { - 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 -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. diff --git a/justfile b/justfile index dce0f02..fca24cf 100644 --- a/justfile +++ b/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 "=========================================="