From 8293392969131e736b1e47f6e1b94b03a92d7697 Mon Sep 17 00:00:00 2001 From: Paul Delafosse Date: Mon, 16 May 2022 14:26:26 +0200 Subject: [PATCH] feat: add 'plugin_trait' feature to pop-launcher-toolkit --- Cargo.lock | 5 ++ toolkit/Cargo.toml | 7 +- toolkit/src/lib.rs | 123 ++++++++++++++++++++++++++++++++-- toolkit/src/plugin_trait.rs | 128 ++++++++++++++++++++++++++++++++++++ 4 files changed, 256 insertions(+), 7 deletions(-) create mode 100644 toolkit/src/plugin_trait.rs diff --git a/Cargo.lock b/Cargo.lock index 4d6e0b1..92800b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1146,9 +1146,14 @@ dependencies = [ name = "pop-launcher-toolkit" version = "0.1.0" dependencies = [ + "async-trait", + "dirs 4.0.0", + "futures", "pop-launcher", "pop-launcher-plugins", "pop-launcher-service", + "tracing", + "tracing-subscriber", ] [[package]] diff --git a/toolkit/Cargo.toml b/toolkit/Cargo.toml index f0fdbf0..c0b250a 100644 --- a/toolkit/Cargo.toml +++ b/toolkit/Cargo.toml @@ -9,4 +9,9 @@ description = "A wrapper around pop-launcher, pop-launcher-service and pop-launc [dependencies] pop-launcher-plugins = { path = "../plugins"} pop-launcher-service = { path = "../service"} -pop-launcher = { path = "../" } \ No newline at end of file +pop-launcher = { path = "../" } +async-trait = "0.1.53" +tracing = "0.1.32" +tracing-subscriber = { version = "0.3.9", default-features = false, features = ["std", "fmt", "env-filter"] } +dirs = "4.0.0" +futures = "0.3.21" \ No newline at end of file diff --git a/toolkit/src/lib.rs b/toolkit/src/lib.rs index cd7aee5..c6c6613 100644 --- a/toolkit/src/lib.rs +++ b/toolkit/src/lib.rs @@ -1,10 +1,121 @@ // Copyright 2021 System76 // SPDX-License-Identifier: MPL-2.0 -pub use pop_launcher_service::{ - self as service, - load::from_path as load_plugin_from_path, - load::from_paths as load_plugins_from_paths -}; -pub use pop_launcher_plugins as plugins; +//! # pop-launcher-toolkit +//! +//! A toolkit to write pop-launcher client and plugin. +//! +//! ## Crates +//! - **[`launcher`]:** re-export the pop-launcher crate, containing all the IPC message struct and +//! some utility functions to locate plugins. +//! - **[`service`]:** re-export the pop-launcher-service crate, containing deserializable plugin config struct. +//! This is useful if your client needs to read user defined plugins configs. +//! - **[`plugins`]:** re-export pop-launcher-plugins which defines all the default pop-launcher plugins. +//! Useful if your client needs to read default plugin configs +//! +//! ## Writing a plugin +//! +//! Add the following to your Cargo.toml : +//! +//! ```toml +//! [dependencies] +//! tokio = { version = "1.18.2", features = ["rt"] } +//! pop-launcher-toolkit = { git = "https://github.com/pop-os/launcher" } +//! ``` +//! +//! And implement the [`PluginExt`] trait: +//! +//! [`PluginExt`]: plugin_trait::PluginExt +//! +//! ```rust +//! use pop_launcher_toolkit::launcher::{Indice, PluginResponse, PluginSearchResult}; +//! use pop_launcher_toolkit::plugin_trait::{async_trait, PluginExt}; +//! use pop_launcher_toolkit::plugins; +//! +//! // The plugin struct, here it holds the search result +//! pub struct MyPlugin { +//! data: Vec +//! } +//! +//! #[async_trait] +//! impl PluginExt for MyPlugin { +//! +//! // Define the name of you plugin, this will be used +//! // to generate a logfile in $XDG_STATE_HOME at runtime. +//! fn name(&self) -> &str { +//! "my_awesome_plugin" +//! } +//! +//! // Respond to `pop-launcher` 'search' query +//! async fn search(&mut self, query: &str) { +//! // `pop-launcher` dispatches request to plugins according to the regex defined in +//! // the `plugin.ron` config file, here we get rid of the prefix +//! // before processing the request. +//! let query = query.strip_prefix("plug ").unwrap(); +//! +//! // Iterate through our internal search results with their indices. +//! let search_results = self.data.iter() +//! .enumerate() +//! .filter(|(idx, data)| data.contains(query)); +//! +//! // Send our search results to `pop-launcher` using their indices as id. +//! for (idx, search_result) in search_results { +//! self.respond_with(PluginResponse::Append(PluginSearchResult { +//! id: idx as u32, +//! name: search_result.clone(), +//! description: "".to_string(), +//! keywords: None, +//! icon: None, +//! exec: None, +//! window: None, +//! })).await; +//! } +//! +//! // tell `pop-launcher` we are done with this request +//! self.respond_with(PluginResponse::Finished).await; +//! } +//! +//! // Respond to `pop-launcher` 'activate' query +//! async fn activate(&mut self, id: Indice) { +//! // Get the selected entry +//! let entry = self.data.get(id as usize).unwrap(); +//! // Here we use xdg_open to run the entry but this could be anything +//! plugins::xdg_open(entry); +//! // Tell pop launcher we are done +//! self.respond_with(PluginResponse::Finished); +//! } +//! +//! // Respond to `pop-launcher` 'close' request. +//! async fn quit(&mut self, id: Indice) { +//! self.respond_with(PluginResponse::Close).await; +//! } +//! } +//! +//! #[tokio::main(flavor = "current_thread")] +//! pub async fn main() { +//! +//! // Here we declare our plugin with dummy values, and never mutate them. +//! // In a real plugin we would probably use some kind of mutable shared reference to +//! // update our search results. +//! let mut plugin = MyPlugin { +//! data: vec!["https://crates.io".to_string(), "https://en.wikipedia.org".to_string()], +//! }; +//! +//! /// If you need to debug your plugin or display error messages use `tcracing` macros. +//! tracing::info!("Starting my_awsome_plugin"); +//! +//! // Call the plugin entry point function to start +//! // talking with pop_launcherc +//! plugin.run().await; +//! } +//! ``` + pub use pop_launcher as launcher; +pub use pop_launcher_plugins as plugins; +pub use pop_launcher_service::{ + self as service, load::from_path as load_plugin_from_path, + load::from_paths as load_plugins_from_paths, +}; + +/// A helper trait to quickly create `pop-launcher` plugins +pub mod plugin_trait; diff --git a/toolkit/src/plugin_trait.rs b/toolkit/src/plugin_trait.rs new file mode 100644 index 0000000..cb5f4db --- /dev/null +++ b/toolkit/src/plugin_trait.rs @@ -0,0 +1,128 @@ +// Copyright 2021 System76 +// SPDX-License-Identifier: MPL-2.0 + +use futures::StreamExt; +use pop_launcher::{async_stdin, async_stdout, json_input_stream, Indice, PluginResponse, Request}; + +pub use async_trait::async_trait; +use pop_launcher_plugins as plugins; + +/// Re-export of the tracing crate, use this to add custom logs to your plugin +pub use tracing; + +/// A helper trait to create `pop-launcher` plugins. +#[async_trait] +pub trait PluginExt +where + Self: Sized + Send, +{ + /// The name of our plugin, currently this is used internally to create the plugin log file at + /// `$XDG_STATE_HOME/pop-launcher/{name}.log` + fn name(&self) -> &str; + + /// Handle a [`Request::Search`] issued by `pop-launcher`. + /// To send search result back use [`PluginResponse::Append`]. + /// Once finished [`PluginResponse::Finished`] is expected to notify the search result are ready to be displayed. + async fn search(&mut self, query: &str); + + /// Define how the plugin should handle [`Request::Activate`] request. + /// Typically run the requested entry (for instance using [`super::plugins::xdg_open`]) + /// and close the client with a [`PluginResponse::Close`] + async fn activate(&mut self, id: Indice); + + /// Define how the plugin should handle [`Request::ActivateContext`] request. + /// Typically run the requested entry with the provided context (for instance using [`super::plugins::xdg_open`]) + /// and close the client with a [`PluginResponse::Close`] + async fn activate_context(&mut self, _id: Indice, _context: Indice) {} + + /// Handle an autocompletion request from the client + async fn complete(&mut self, _id: Indice) {} + + /// `pop-launcher` request the context for the given [`SearchResult`] id. + /// to send the requested context use [`PluginResponse::Context`] + /// + /// [`SearchResult`]: pop_launcher::SearchResult + async fn context(&mut self, _id: Indice) {} + + /// This is automatically called after `pop-launcher` requests the plugin to exit. + /// Use this only if your plugin does not need to perform specific clean ups. + fn exit(&mut self) {} + + /// Whenever a new search query is issued, `pop-launcher` will send a [`Request::Interrupt`] + /// so we can stop any ongoing computation before handling the next query. + /// This is especially useful for plugins that rely on external services + /// to get their search results (a HTTP endpoint for instance) + async fn interrupt(&mut self) {} + + /// The launcher is asking us to quit a specific item. + async fn quit(&mut self, _id: Indice) {} + + /// A helper function to send [`PluginResponse`] back to `pop-launcher` + async fn respond_with(&self, response: PluginResponse) { + plugins::send(&mut async_stdout(), response).await + } + + /// Run the plugin + async fn run(&mut self) { + self.init_logging(); + let mut receiver = json_input_stream(async_stdin()); + while let Some(request) = receiver.next().await { + tracing::event!( + tracing::Level::DEBUG, + "{}: received {:?}", + self.name(), + request + ); + + match request { + Ok(request) => match request { + Request::Search(query) => self.search(&query).await, + Request::Interrupt => self.interrupt().await, + Request::Activate(id) => self.activate(id).await, + Request::ActivateContext { id, context } => { + self.activate_context(id, context).await + } + Request::Complete(id) => self.complete(id).await, + Request::Context(id) => self.context(id).await, + Request::Quit(id) => self.quit(id).await, + Request::Exit => { + self.exit(); + break; + } + }, + Err(why) => tracing::error!("Malformed json request: {why}"), + } + } + + tracing::event!(tracing::Level::DEBUG, "{}: exiting plugin", self.name()); + } + + fn init_logging(&self) { + let logdir = match dirs::state_dir() { + Some(dir) => dir.join("pop-launcher/"), + None => dirs::home_dir() + .expect("home directory required") + .join(".cache/pop-launcher"), + }; + + let _ = std::fs::create_dir_all(&logdir); + + let logfile = std::fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open( + logdir + .join([self.name(), ".log"].concat().as_str()) + .as_path(), + ); + + if let Ok(file) = logfile { + use tracing_subscriber::{fmt, EnvFilter}; + fmt() + .with_env_filter(EnvFilter::from_default_env()) + .with_writer(file) + .init(); + } + } +}