Core changes: - Simplified ProviderType enum to 4 core types + Plugin(String) - Added priority field to plugin API (API_VERSION = 3) - Removed hardcoded plugin-specific code from core - Updated filter.rs to use Plugin(type_id) for all plugins - Updated main_window.rs UI mappings to derive from type_id - Fixed weather/media SVG icon colors Plugin changes: - All plugins now declare their own priority values - Widget plugins: weather(12000), pomodoro(11500), media(11000) - Dynamic plugins: calc(10000), websearch(9000), filesearch(8000) - Static plugins: priority 0 (frecency-based) Bookmarks plugin: - Replaced SQLx with rusqlite + bundled SQLite - Fixes "undefined symbol: sqlite3_db_config" build errors - No longer depends on system SQLite version Config: - Fixed config.example.toml invalid nested TOML sections - Removed [providers.websearch], [providers.weather], etc. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
291 lines
8.7 KiB
Rust
291 lines
8.7 KiB
Rust
//! Scripts Plugin for Owlry
|
|
//!
|
|
//! A static provider that scans `~/.local/share/owlry/scripts/` for executable
|
|
//! scripts and provides them as launch items.
|
|
//!
|
|
//! Scripts can include a description by adding a comment after the shebang:
|
|
//! ```bash
|
|
//! #!/bin/bash
|
|
//! # This is my script description
|
|
//! echo "Hello"
|
|
//! ```
|
|
|
|
use abi_stable::std_types::{ROption, RStr, RString, RVec};
|
|
use owlry_plugin_api::{
|
|
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
|
|
ProviderPosition, API_VERSION,
|
|
};
|
|
use std::fs;
|
|
use std::os::unix::fs::PermissionsExt;
|
|
use std::path::PathBuf;
|
|
|
|
// Plugin metadata
|
|
const PLUGIN_ID: &str = "scripts";
|
|
const PLUGIN_NAME: &str = "Scripts";
|
|
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
|
|
const PLUGIN_DESCRIPTION: &str = "Run user scripts from ~/.local/share/owlry/scripts/";
|
|
|
|
// Provider metadata
|
|
const PROVIDER_ID: &str = "scripts";
|
|
const PROVIDER_NAME: &str = "Scripts";
|
|
const PROVIDER_PREFIX: &str = ":script";
|
|
const PROVIDER_ICON: &str = "utilities-terminal";
|
|
const PROVIDER_TYPE_ID: &str = "scripts";
|
|
|
|
/// Scripts provider state - holds cached items
|
|
struct ScriptsState {
|
|
items: Vec<PluginItem>,
|
|
}
|
|
|
|
impl ScriptsState {
|
|
fn new() -> Self {
|
|
Self { items: Vec::new() }
|
|
}
|
|
|
|
fn scripts_dir() -> Option<PathBuf> {
|
|
dirs::data_dir().map(|d| d.join("owlry").join("scripts"))
|
|
}
|
|
|
|
fn load_scripts(&mut self) {
|
|
self.items.clear();
|
|
|
|
let scripts_dir = match Self::scripts_dir() {
|
|
Some(p) => p,
|
|
None => return,
|
|
};
|
|
|
|
if !scripts_dir.exists() {
|
|
// Create the directory for the user
|
|
let _ = fs::create_dir_all(&scripts_dir);
|
|
return;
|
|
}
|
|
|
|
let entries = match fs::read_dir(&scripts_dir) {
|
|
Ok(e) => e,
|
|
Err(_) => return,
|
|
};
|
|
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
|
|
// Skip directories
|
|
if path.is_dir() {
|
|
continue;
|
|
}
|
|
|
|
// Check if executable
|
|
let metadata = match path.metadata() {
|
|
Ok(m) => m,
|
|
Err(_) => continue,
|
|
};
|
|
|
|
let is_executable = metadata.permissions().mode() & 0o111 != 0;
|
|
if !is_executable {
|
|
continue;
|
|
}
|
|
|
|
// Get script name without extension
|
|
let filename = path
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().to_string())
|
|
.unwrap_or_default();
|
|
|
|
let name = path
|
|
.file_stem()
|
|
.map(|n| n.to_string_lossy().to_string())
|
|
.unwrap_or(filename.clone());
|
|
|
|
// Try to read description from first line comment
|
|
let description = Self::read_script_description(&path);
|
|
|
|
// Determine icon based on extension or shebang
|
|
let icon = Self::determine_icon(&path);
|
|
|
|
let mut item = PluginItem::new(
|
|
format!("script:{}", filename),
|
|
format!("Script: {}", name),
|
|
path.to_string_lossy().to_string(),
|
|
)
|
|
.with_icon(icon)
|
|
.with_keywords(vec!["script".to_string()]);
|
|
|
|
if let Some(desc) = description {
|
|
item = item.with_description(desc);
|
|
}
|
|
|
|
self.items.push(item);
|
|
}
|
|
}
|
|
|
|
fn read_script_description(path: &PathBuf) -> Option<String> {
|
|
let content = fs::read_to_string(path).ok()?;
|
|
let mut lines = content.lines();
|
|
|
|
// Skip shebang if present
|
|
let first_line = lines.next()?;
|
|
let check_line = if first_line.starts_with("#!") {
|
|
lines.next()?
|
|
} else {
|
|
first_line
|
|
};
|
|
|
|
// Look for a comment description
|
|
if let Some(desc) = check_line.strip_prefix("# ") {
|
|
Some(desc.trim().to_string())
|
|
} else { check_line.strip_prefix("// ").map(|desc| desc.trim().to_string()) }
|
|
}
|
|
|
|
fn determine_icon(path: &PathBuf) -> String {
|
|
// Check extension first
|
|
if let Some(ext) = path.extension() {
|
|
match ext.to_string_lossy().as_ref() {
|
|
"sh" | "bash" | "zsh" => return "utilities-terminal".to_string(),
|
|
"py" | "python" => return "text-x-python".to_string(),
|
|
"js" | "ts" => return "text-x-javascript".to_string(),
|
|
"rb" => return "text-x-ruby".to_string(),
|
|
"pl" => return "text-x-perl".to_string(),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// Check shebang
|
|
if let Ok(content) = fs::read_to_string(path)
|
|
&& let Some(first_line) = content.lines().next() {
|
|
if first_line.contains("bash") || first_line.contains("sh") {
|
|
return "utilities-terminal".to_string();
|
|
} else if first_line.contains("python") {
|
|
return "text-x-python".to_string();
|
|
} else if first_line.contains("node") {
|
|
return "text-x-javascript".to_string();
|
|
} else if first_line.contains("ruby") {
|
|
return "text-x-ruby".to_string();
|
|
}
|
|
}
|
|
|
|
"application-x-executable".to_string()
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Plugin Interface Implementation
|
|
// ============================================================================
|
|
|
|
extern "C" fn plugin_info() -> PluginInfo {
|
|
PluginInfo {
|
|
id: RString::from(PLUGIN_ID),
|
|
name: RString::from(PLUGIN_NAME),
|
|
version: RString::from(PLUGIN_VERSION),
|
|
description: RString::from(PLUGIN_DESCRIPTION),
|
|
api_version: API_VERSION,
|
|
}
|
|
}
|
|
|
|
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
|
|
vec![ProviderInfo {
|
|
id: RString::from(PROVIDER_ID),
|
|
name: RString::from(PROVIDER_NAME),
|
|
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
|
|
icon: RString::from(PROVIDER_ICON),
|
|
provider_type: ProviderKind::Static,
|
|
type_id: RString::from(PROVIDER_TYPE_ID),
|
|
position: ProviderPosition::Normal,
|
|
priority: 0, // Static: use frecency ordering
|
|
}]
|
|
.into()
|
|
}
|
|
|
|
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
|
|
let state = Box::new(ScriptsState::new());
|
|
ProviderHandle::from_box(state)
|
|
}
|
|
|
|
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
|
|
if handle.ptr.is_null() {
|
|
return RVec::new();
|
|
}
|
|
|
|
// SAFETY: We created this handle from Box<ScriptsState>
|
|
let state = unsafe { &mut *(handle.ptr as *mut ScriptsState) };
|
|
|
|
// Load scripts
|
|
state.load_scripts();
|
|
|
|
// Return items
|
|
state.items.to_vec().into()
|
|
}
|
|
|
|
extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec<PluginItem> {
|
|
// Static provider - query is handled by the core using cached items
|
|
RVec::new()
|
|
}
|
|
|
|
extern "C" fn provider_drop(handle: ProviderHandle) {
|
|
if !handle.ptr.is_null() {
|
|
// SAFETY: We created this handle from Box<ScriptsState>
|
|
unsafe {
|
|
handle.drop_as::<ScriptsState>();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Register the plugin vtable
|
|
owlry_plugin! {
|
|
info: plugin_info,
|
|
providers: plugin_providers,
|
|
init: provider_init,
|
|
refresh: provider_refresh,
|
|
query: provider_query,
|
|
drop: provider_drop,
|
|
}
|
|
|
|
// ============================================================================
|
|
// Tests
|
|
// ============================================================================
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_scripts_state_new() {
|
|
let state = ScriptsState::new();
|
|
assert!(state.items.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_determine_icon_sh() {
|
|
let path = PathBuf::from("/test/script.sh");
|
|
let icon = ScriptsState::determine_icon(&path);
|
|
assert_eq!(icon, "utilities-terminal");
|
|
}
|
|
|
|
#[test]
|
|
fn test_determine_icon_python() {
|
|
let path = PathBuf::from("/test/script.py");
|
|
let icon = ScriptsState::determine_icon(&path);
|
|
assert_eq!(icon, "text-x-python");
|
|
}
|
|
|
|
#[test]
|
|
fn test_determine_icon_js() {
|
|
let path = PathBuf::from("/test/script.js");
|
|
let icon = ScriptsState::determine_icon(&path);
|
|
assert_eq!(icon, "text-x-javascript");
|
|
}
|
|
|
|
#[test]
|
|
fn test_determine_icon_unknown() {
|
|
let path = PathBuf::from("/test/script.xyz");
|
|
let icon = ScriptsState::determine_icon(&path);
|
|
assert_eq!(icon, "application-x-executable");
|
|
}
|
|
|
|
#[test]
|
|
fn test_scripts_dir() {
|
|
// Should return Some path
|
|
let dir = ScriptsState::scripts_dir();
|
|
assert!(dir.is_some());
|
|
assert!(dir.unwrap().ends_with("owlry/scripts"));
|
|
}
|
|
}
|