feat: plugin system shamelessly stolen from findex (and customized)

This commit is contained in:
imgurbot12 2023-01-04 01:10:08 -07:00
parent 40a64fb533
commit 18e8cb978c
13 changed files with 850 additions and 1 deletions

3
.gitignore vendored
View file

@ -1 +1,2 @@
/target
target
Cargo.lock

View file

@ -0,0 +1,17 @@
[package]
name = "rmenu-plugin"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[features]
cache = ["dep:bincode", "dep:serde"]
rmenu_internals = ["dep:libloading"]
[dependencies]
abi_stable = "0.11.1"
bincode = { version = "1.3.3", optional = true }
lastlog = { version = "0.2.3", features = ["libc"] }
libloading = { version = "0.7.4", optional = true }
serde = { version = "1.0.152", features = ["derive"], optional = true }

View file

@ -0,0 +1,196 @@
/*
* Plugin Optional Cache Implementation for Convenient Storage
*/
use std::collections::HashMap;
use std::io::{Error, ErrorKind, Result, Write};
use std::path::Path;
use std::path::PathBuf;
use std::time::{Duration, SystemTime};
use std::{env, fs};
use abi_stable::{std_types::RString, StableAbi};
use lastlog::search_self;
use super::{Entries, ModuleConfig};
/* Variables */
static HOME: &str = "HOME";
static XDG_CACHE_HOME: &str = "XDG_CACHE_HOME";
/* Functions */
/// Retrieve xdg-cache directory or get default
pub fn get_cache_dir() -> std::result::Result<String, String> {
let path = if let Ok(xdg) = env::var(XDG_CACHE_HOME) {
Path::new(&xdg).to_path_buf()
} else if let Ok(home) = env::var(HOME) {
Path::new(&home).join(".cache")
} else {
Path::new("/tmp/").to_path_buf()
};
match path.join("rmenu").to_str() {
Some(s) => Ok(s.to_owned()),
None => Err(format!("Failed to read $XDG_CACHE_DIR or $HOME")),
}
}
/// Retrieve standard cache-path variable from config or set to default
#[inline]
pub fn get_cache_dir_setting(cfg: &ModuleConfig) -> PathBuf {
Path::new(
match cfg.get("cache_path") {
Some(path) => RString::from(path.as_str()),
None => RString::from(get_cache_dir().unwrap()),
}
.as_str(),
)
.to_path_buf()
}
/// Retrieve standard cache-mode variable from config or set to default
#[inline]
pub fn get_cache_setting(cfg: &ModuleConfig, default: CacheSetting) -> CacheSetting {
cfg.get("cache_mode")
.unwrap_or(&RString::from(""))
.parse::<CacheSetting>()
.unwrap_or(default)
}
/* Module */
/// Configured Module Cache settings
#[repr(C)]
#[derive(Debug, Clone, PartialEq, StableAbi)]
pub enum CacheSetting {
Never,
Forever,
OnLogin,
After(u64),
}
impl std::str::FromStr for CacheSetting {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
if s.chars().all(char::is_numeric) {
let i = s.parse::<u64>().map_err(|e| format!("{e}"))?;
return Ok(CacheSetting::After(i));
};
match s {
"never" => Ok(CacheSetting::Never),
"forever" => Ok(CacheSetting::Forever),
"login" => Ok(CacheSetting::OnLogin),
_ => Err(format!("invalid value: {:?}", s)),
}
}
}
/// Simple Entry cache implementation
pub struct Cache {
path: PathBuf,
cache: HashMap<String, Entries>,
}
impl Cache {
pub fn new(path: PathBuf) -> Self {
Self {
path,
cache: HashMap::new(),
}
}
// attempt to read given cache entry from disk
fn load(&self, name: &str, valid: &CacheSetting) -> Result<Entries> {
// skip all checks if caching is disabled
let expr_err = Error::new(ErrorKind::InvalidData, "cache expired");
if valid == &CacheSetting::Never {
return Err(expr_err);
}
// check if the given path exists
let path = self.path.join(name);
if !path.is_file() {
return Err(Error::new(ErrorKind::NotFound, "no such file"));
}
// get last-modified date of file
let meta = path.metadata()?;
let modified = meta.modified()?;
// handle expiration based on cache-setting
match valid {
CacheSetting::Never => return Err(expr_err),
CacheSetting::Forever => {}
CacheSetting::OnLogin => {
// expire content if it was last modified before last login
if let Ok(record) = search_self() {
if let Some(lastlog) = record.last_login.into() {
if modified <= lastlog {
return Err(expr_err);
}
}
}
}
CacheSetting::After(secs) => {
// expire content if it was last modified longer than duration
let now = SystemTime::now();
let duration = Duration::from_secs(*secs);
if now - duration >= modified {
return Err(expr_err);
}
}
};
// load entries from bincode
let data = fs::read(path)?;
let entries: Entries = bincode::deserialize(&data).map_err(|_| ErrorKind::InvalidData)?;
Ok(entries)
}
/// Returns true if the given key was found in cache memory
pub fn contains_key(&self, name: &str) -> bool {
self.cache.contains_key(name)
}
/// Read all cached entries associated w/ the specified key
pub fn read(&mut self, name: &str, valid: &CacheSetting) -> Result<&Entries> {
// return cache entry if already cached
if self.cache.contains_key(name) {
return Ok(self.cache.get(name).expect("read failed cache grab"));
}
// load entries from cache on disk
let entries = self.load(name, valid)?;
self.cache.insert(name.to_owned(), entries);
Ok(self.cache.get(name).expect("cached entries are missing?"))
}
/// Write all entries associated w/ the specified key to cache
pub fn write(&mut self, name: &str, valid: &CacheSetting, entries: Entries) -> Result<()> {
// write to runtime cache reguardless of cache settings
self.cache.insert(name.to_owned(), entries);
// skip caching if disabled
if valid == &CacheSetting::Never {
return Ok(());
}
// retrieve entries passed to cache
let entries = self.cache.get(name).expect("write failed cache grab");
// serialize entries and write to cache
let path = self.path.join(name);
let mut f = fs::File::create(path)?;
let data = bincode::serialize(entries).map_err(|_| ErrorKind::InvalidInput)?;
f.write_all(&data)?;
Ok(())
}
/// easy functional wrapper that ensures data is always loaded once and then cached
pub fn wrap<F>(&mut self, name: &str, valid: &CacheSetting, func: F) -> Result<Entries>
where
F: FnOnce() -> Entries,
{
let entries = match self.read(name, valid) {
Ok(entries) => entries,
Err(_) => {
self.write(name, valid, func())?;
self.read(name, valid)?
}
};
Ok(entries.clone())
}
}

View file

@ -0,0 +1,33 @@
/*
* Internal Library Loading Implementation
*/
use abi_stable::std_types::{RBox, RHashMap, RString};
use libloading::{Error, Library, Symbol};
use super::{Module, ModuleConfig};
/* Types */
pub struct Plugin {
pub lib: Library,
pub module: Box<dyn Module>,
}
type LoadFunc = unsafe extern "C" fn(&ModuleConfig) -> Box<dyn Module>;
/* Functions */
pub unsafe fn load_plugin(path: &str, cfg: &ModuleConfig) -> Result<Plugin, Error> {
// Load and initialize library
#[cfg(target_os = "linux")]
let lib: Library = {
// Load library with `RTLD_NOW | RTLD_NODELETE` to fix a SIGSEGV
// https://github.com/nagisa/rust_libloading/issues/41#issuecomment-448303856
libloading::os::unix::Library::open(Some(path), 0x2 | 0x1000)?.into()
};
#[cfg(not(target_os = "linux"))]
let lib = Library::new(path.as_ref())?;
// load module object and generate plugin
let module = lib.get::<Symbol<LoadFunc>>(b"load_module")?(cfg);
Ok(Plugin { lib, module })
}

View file

@ -0,0 +1,66 @@
#[cfg(feature = "rmenu_internals")]
pub mod internal;
#[cfg(feature = "cache")]
pub mod cache;
use abi_stable::std_types::*;
use abi_stable::{sabi_trait, StableAbi};
#[cfg(feature = "cache")]
use serde::{Deserialize, Serialize};
/// Configured Entry Execution settings
#[repr(C)]
#[derive(Debug, Clone, StableAbi)]
#[cfg_attr(feature = "cache", derive(Serialize, Deserialize))]
pub enum Exec {
Command(RString),
Terminal(RString),
}
/// Module search-entry Icon configuration
#[repr(C)]
#[derive(Debug, Clone, StableAbi)]
#[cfg_attr(feature = "cache", derive(Serialize, Deserialize))]
pub struct Icon {
pub name: RString,
pub data: RVec<u8>,
}
/// Module single search-entry
#[repr(C)]
#[derive(Debug, Clone, StableAbi)]
#[cfg_attr(feature = "cache", derive(Serialize, Deserialize))]
pub struct Entry {
pub name: RString,
pub exec: Exec,
pub comment: ROption<RString>,
pub icon: ROption<Icon>,
}
/// Alias for FFI-safe vector of `Entry` object
pub type Entries = RVec<Entry>;
/// Alias for FFI-Safe hashmap for config entries
pub type ModuleConfig = RHashMap<RString, RString>;
/// Module trait abstraction for all search plugins
#[sabi_trait]
pub trait Module {
extern "C" fn name(&self) -> RString;
extern "C" fn prefix(&self) -> RString;
extern "C" fn search(&mut self, search: RString) -> Entries;
}
/// Generates the required rmenu plugin resources
#[macro_export]
macro_rules! export_plugin {
($module:ident) => {
#[no_mangle]
extern "C" fn load_module(config: &ModuleConfig) -> Box<dyn Module> {
let inst = $module::new(config);
Box::new(inst)
}
};
}

10
crates/rmenu/Cargo.toml Normal file
View file

@ -0,0 +1,10 @@
[package]
name = "rmenu"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
abi_stable = "0.11.1"
rmenu-plugin = { version = "0.1.0", path = "../rmenu-plugin", features = ["rmenu_internals"] }

20
crates/rmenu/src/main.rs Normal file
View file

@ -0,0 +1,20 @@
use abi_stable::std_types::{RHashMap, RString};
use rmenu_plugin::internal::load_plugin;
static PLUGIN: &str = "../../plugins/run/target/release/librun.so";
fn test() {
let mut cfg = RHashMap::new();
// cfg.insert(RString::from("ignore_case"), RString::from("true"));
let mut plugin = unsafe { load_plugin(PLUGIN, &cfg).unwrap() };
let results = plugin.module.search(RString::from("br"));
for result in results.into_iter() {
println!("{} - {:?}", result.name, result.comment);
}
println!("ayy lmao done!");
}
fn main() {
test();
}

16
plugins/drun/Cargo.toml Normal file
View file

@ -0,0 +1,16 @@
[package]
name = "drun"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
abi_stable = "0.11.1"
freedesktop_entry_parser = "1.3.0"
regex = "1.7.0"
rmenu-plugin = { version = "0.1.0", path = "../../crates/rmenu-plugin", features = ["cache"] }
walkdir = "2.3.2"

135
plugins/drun/src/desktop.rs Normal file
View file

@ -0,0 +1,135 @@
use std::collections::HashMap;
use std::fs;
use abi_stable::std_types::{RNone, ROption, RSome, RString, RVec};
use freedesktop_entry_parser::parse_entry;
use walkdir::{DirEntry, WalkDir};
use rmenu_plugin::*;
/* Types */
#[derive(Debug)]
struct IconFile {
name: String,
path: String,
size: u64,
}
/* Functions */
// filter out invalid icon entries
fn is_icon(entry: &DirEntry) -> bool {
entry.file_type().is_dir()
|| entry
.file_name()
.to_str()
.map(|s| s.ends_with(".svg") || s.ends_with(".png"))
.unwrap_or(false)
}
// filter out invalid desktop entries
fn is_desktop(entry: &DirEntry) -> bool {
entry.file_type().is_dir()
|| entry
.file_name()
.to_str()
.map(|s| s.ends_with(".desktop"))
.unwrap_or(false)
}
// correlate name w/ best matched icon
#[inline]
fn match_icon(name: &str, icons: &Vec<IconFile>) -> Option<String> {
for icon in icons.iter() {
if icon.name == name {
return Some(icon.path.to_owned());
}
let Some((fname, _)) = icon.name.rsplit_once('.') else { continue };
if fname == name {
return Some(icon.path.to_owned());
}
}
None
}
// correlate name w/ best matched icon and read into valid entry
#[inline]
fn read_icon(name: &str, icons: &Vec<IconFile>) -> Option<Icon> {
let path = match_icon(name, icons)?;
let Ok(data) = fs::read(&path) else { return None };
Some(Icon {
name: RString::from(name),
data: RVec::from(data),
})
}
// retrieve master-list of all possible xdg-application entries from filesystem
pub fn load_entries(app_paths: &Vec<String>, icon_paths: &Vec<String>) -> Vec<Entry> {
// iterate and collect all existing icon paths
let mut imap: HashMap<String, IconFile> = HashMap::new();
for path in icon_paths.into_iter() {
let walker = WalkDir::new(path).into_iter();
for entry in walker.filter_entry(is_icon) {
let Ok(dir) = entry else { continue };
let Ok(meta) = dir.metadata() else { continue };
let Some(name) = dir.file_name().to_str() else { continue };
let Some(path) = dir.path().to_str() else { continue; };
let size = meta.len();
// find the biggest icon file w/ the same name
let pathstr = path.to_owned();
if let Some(icon) = imap.get_mut(name) {
if icon.size < size {
icon.path = pathstr;
icon.size = size;
}
continue;
}
imap.insert(
name.to_owned(),
IconFile {
name: name.to_owned(),
path: pathstr,
size,
},
);
}
}
// parse application entries
let icons = imap.into_values().collect();
let mut entries = vec![];
for path in app_paths.into_iter() {
let walker = WalkDir::new(path).into_iter();
for entry in walker.filter_entry(is_desktop) {
let Ok(dir) = entry else { continue };
let Ok(file) = parse_entry(dir.path()) else { continue };
let desktop = file.section("Desktop Entry");
let Some(name) = desktop.attr("Name") else { continue };
let Some(exec) = desktop.attr("Exec") else { continue };
let terminal = desktop.attr("Terminal").unwrap_or("") == "true";
// parse icon
let icon = match desktop.attr("Icon") {
Some(name) => ROption::from(read_icon(name, &icons)),
None => RNone,
};
// parse comment
let comment = match desktop.attr("Comment") {
Some(attr) => RSome(RString::from(attr)),
None => RNone,
};
// convert exec/terminal into command
let command = match terminal {
true => Exec::Terminal(RString::from(exec)),
false => Exec::Command(RString::from(exec)),
};
// generate entry
entries.push(Entry {
name: RString::from(name),
exec: command,
comment,
icon,
});
}
}
entries
}

156
plugins/drun/src/lib.rs Normal file
View file

@ -0,0 +1,156 @@
use std::env;
use std::io::Result;
use std::path::{Path, PathBuf};
use abi_stable::std_types::*;
use regex::{Regex, RegexBuilder};
use rmenu_plugin::{cache::*, *};
mod desktop;
use desktop::load_entries;
/* Variables */
static NAME: &str = "drun";
static PREFIX: &str = "app";
static XDG_DATA_DIRS: &str = "XDG_DATA_DIRS";
static DEFAULT_XDG_PATHS: &str = "/usr/share/";
static DEFAULT_APP_PATHS: &str = "";
static DEFAULT_ICON_PATHS: &str = "/usr/share/pixmaps/";
/* Functions */
// parse path string into separate path entries
#[inline]
fn parse_config_paths(paths: String) -> Vec<String> {
env::split_paths(&paths)
.map(|s| s.to_str().expect("invalid path").to_owned())
.collect()
}
// retrieve default xdg-paths using xdg environment variable when possible
#[inline]
fn default_xdg_paths() -> String {
if let Ok(paths) = env::var(XDG_DATA_DIRS) {
return paths.to_owned();
}
DEFAULT_XDG_PATHS.to_owned()
}
// append joined xdg-paths to app/icon path results
#[inline]
fn apply_paths(join: &str, paths: &Vec<String>) -> Vec<String> {
paths
.iter()
.map(|s| {
Path::new(s)
.join(join)
.to_str()
.expect("Unable to join PATH")
.to_owned()
})
.collect()
}
// regex validate if the following entry matches the given regex expression
#[inline]
pub fn is_match(entry: &Entry, search: &Regex) -> bool {
if search.is_match(&entry.name) {
return true;
};
if let RSome(comment) = entry.comment.as_ref() {
return search.is_match(&comment);
}
false
}
/* Macros */
macro_rules! pathify {
($cfg:expr, $key:expr, $other:expr) => {
parse_config_paths(match $cfg.get($key) {
Some(path) => path.as_str().to_owned(),
None => $other,
})
};
}
/* Plugin */
struct Settings {
xdg_paths: Vec<String>,
app_paths: Vec<String>,
icon_paths: Vec<String>,
cache_path: PathBuf,
cache_mode: CacheSetting,
ignore_case: bool,
}
struct DesktopRun {
cache: Cache,
settings: Settings,
}
impl DesktopRun {
pub fn new(cfg: &ModuleConfig) -> Self {
let settings = Settings {
xdg_paths: pathify!(cfg, "xdg_paths", default_xdg_paths()),
app_paths: pathify!(cfg, "app_paths", DEFAULT_APP_PATHS.to_owned()),
icon_paths: pathify!(cfg, "icon_paths", DEFAULT_ICON_PATHS.to_owned()),
ignore_case: cfg
.get("ignore_case")
.unwrap_or(&RString::from("true"))
.parse()
.unwrap_or(true),
cache_path: get_cache_dir_setting(cfg),
cache_mode: get_cache_setting(cfg, CacheSetting::OnLogin),
};
Self {
cache: Cache::new(settings.cache_path.clone()),
settings,
}
}
fn load(&mut self) -> Result<Entries> {
self.cache.wrap(NAME, &self.settings.cache_mode, || {
// configure paths w/ xdg-paths
let mut app_paths = apply_paths("applications", &self.settings.xdg_paths);
let mut icon_paths = apply_paths("icons", &self.settings.xdg_paths);
app_paths.append(&mut self.settings.app_paths.clone());
icon_paths.append(&mut self.settings.icon_paths.clone());
// generate search results
let mut entries = load_entries(&app_paths, &icon_paths);
entries.sort_by_cached_key(|s| s.name.to_owned());
RVec::from(entries)
})
}
}
impl Module for DesktopRun {
extern "C" fn name(&self) -> RString {
RString::from(NAME)
}
extern "C" fn prefix(&self) -> RString {
RString::from(PREFIX)
}
extern "C" fn search(&mut self, search: RString) -> Entries {
// compile regex expression for the given search
let mut matches = RVec::new();
let Ok(rgx) = RegexBuilder::new(search.as_str())
.case_insensitive(self.settings.ignore_case)
.build() else { return matches };
// retrieve entries based on declared modes
let Ok(entries) = self.load() else { return matches };
// search existing entries for matching regex expr
for entry in entries.into_iter() {
if is_match(&entry, &rgx) {
matches.push(entry);
}
}
matches
}
}
export_plugin!(DesktopRun);

15
plugins/run/Cargo.toml Normal file
View file

@ -0,0 +1,15 @@
[package]
name = "run"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
abi_stable = "0.11.1"
regex = "1.7.0"
rmenu-plugin = { version = "0.1.0", path = "../../crates/rmenu-plugin", features = ["cache"] }
walkdir = "2.3.2"

105
plugins/run/src/lib.rs Normal file
View file

@ -0,0 +1,105 @@
/*!
* Binary/Executable App Search Module
*/
use std::env;
use std::io::Result;
use std::path::PathBuf;
use abi_stable::std_types::{RString, RVec};
use regex::RegexBuilder;
use rmenu_plugin::{cache::*, *};
mod run;
use run::find_executables;
/* Variables */
static NAME: &str = "run";
static PREFIX: &str = "exec";
static PATH_VAR: &str = "PATH";
/* Functions */
// parse path string into separate path entries
#[inline]
fn parse_config_paths(paths: &str) -> Vec<String> {
env::split_paths(paths)
.map(|s| s.to_str().expect("invalid path").to_owned())
.collect()
}
// get all paths listed in $PATH env variable
#[inline]
fn get_paths() -> Vec<String> {
parse_config_paths(&env::var(PATH_VAR).expect("Unable to read $PATH"))
}
/* Module */
struct Settings {
paths: Vec<String>,
cache_path: PathBuf,
cache_mode: CacheSetting,
ignore_case: bool,
}
struct Run {
cache: Cache,
settings: Settings,
}
impl Run {
pub fn new(cfg: &ModuleConfig) -> Self {
let settings = Settings {
ignore_case: cfg
.get("ignore_case")
.unwrap_or(&RString::from("true"))
.parse()
.unwrap_or(true),
paths: match cfg.get("paths") {
Some(paths) => parse_config_paths(paths.as_str()),
None => get_paths(),
},
cache_path: get_cache_dir_setting(cfg),
cache_mode: get_cache_setting(cfg, CacheSetting::After(30)),
};
Run {
cache: Cache::new(settings.cache_path.clone()),
settings,
}
}
fn load(&mut self) -> Result<Entries> {
self.cache.wrap(NAME, &self.settings.cache_mode, || {
RVec::from(find_executables(&self.settings.paths))
})
}
}
impl Module for Run {
extern "C" fn name(&self) -> RString {
RString::from(NAME)
}
extern "C" fn prefix(&self) -> RString {
RString::from(PREFIX)
}
extern "C" fn search(&mut self, search: RString) -> Entries {
// compile regex expression for the given search
let mut matches = RVec::new();
let Ok(rgx) = RegexBuilder::new(search.as_str())
.case_insensitive(self.settings.ignore_case)
.build() else { return matches };
// load entries and evaluate matches
let entries = self.load().expect("failed to parse through $PATH");
for entry in entries.into_iter() {
if rgx.is_match(entry.name.as_str()) {
matches.push(entry);
}
}
matches
}
}
export_plugin!(Run);

79
plugins/run/src/run.rs Normal file
View file

@ -0,0 +1,79 @@
use std::collections::HashMap;
use std::os::unix::fs::PermissionsExt;
use abi_stable::std_types::{RNone, RString};
use rmenu_plugin::{Entry, Exec};
use walkdir::{DirEntry, WalkDir};
/* Functions */
// check if file is executable
fn is_exec(entry: &DirEntry) -> bool {
if entry.file_type().is_dir() {
return true;
}
let Ok(meta) = entry.metadata() else { return false };
meta.permissions().mode() & 0o111 != 0
}
// find all executables within the given paths
#[inline]
pub fn find_executables(paths: &Vec<String>) -> Vec<Entry> {
let mut execs: HashMap<String, Entry> = HashMap::new();
for path in paths.iter() {
let walker = WalkDir::new(path).into_iter();
for entry in walker.filter_entry(is_exec) {
let Ok(dir) = entry else { continue };
let Some(name) = dir.file_name().to_str() else { continue };
let Some(path) = dir.path().to_str() else { continue; };
// check if entry already exists but replace on longer path
if let Some(entry) = execs.get(name) {
if let Exec::Terminal(ref exec) = entry.exec {
if exec.len() >= path.len() {
continue;
}
}
}
execs.insert(
name.to_owned(),
Entry {
name: RString::from(name),
exec: Exec::Terminal(RString::from(path)),
comment: RNone,
icon: RNone,
},
);
}
}
execs.into_values().collect()
}
/* Module */
// pub struct RunModule {}
//
// impl Module for RunModule {
// fn name(&self) -> &str {
// "run"
// }
//
// fn mode(&self) -> Mode {
// Mode::Run
// }
//
// fn cache_setting(&self, settings: &Settings) -> Cache {
// match settings.run.cache.as_ref() {
// Some(cache) => cache.clone(),
// None => Cache::After(Duration::new(30, 0)),
// }
// }
//
// fn load(&self, settings: &Settings) -> Vec<Entry> {
// let cfg = &settings.run;
// let paths = match cfg.paths.as_ref() {
// Some(paths) => paths.clone(),
// None => get_paths(),
// };
// find_executables(&paths)
// }
// }