(feat) Add ability to disable plugins and show bundled plugins (#337)

This commit is contained in:
Gregory Schier
2026-01-01 09:32:48 -08:00
committed by GitHub
parent 07ea1ea7dc
commit 92a8da03af
41 changed files with 515 additions and 1183 deletions

View File

@@ -1276,7 +1276,7 @@ async fn cmd_install_plugin<R: Runtime>(
app_handle: AppHandle<R>,
window: WebviewWindow<R>,
) -> YaakResult<Plugin> {
plugin_manager.add_plugin_by_dir(&PluginContext::new(&window), &directory).await?;
plugin_manager.add_plugin_by_dir(&PluginContext::new(&window), &directory, true).await?;
Ok(app_handle.db().upsert_plugin(
&Plugin { directory: directory.into(), url, ..Default::default() },

View File

@@ -21,10 +21,10 @@ use yaak_plugins::error::Error::PluginErr;
use yaak_plugins::events::{
Color, DeleteKeyValueResponse, EmptyPayload, ErrorResponse, FindHttpResponsesResponse,
GetCookieValueResponse, GetHttpRequestByIdResponse, GetKeyValueResponse, Icon, InternalEvent,
InternalEventPayload, ListCookieNamesResponse, ListHttpRequestsResponse, ListWorkspacesResponse,
RenderGrpcRequestResponse, RenderHttpRequestResponse, SendHttpRequestResponse,
SetKeyValueResponse, ShowToastRequest, TemplateRenderResponse, WindowInfoResponse,
WindowNavigateEvent, WorkspaceInfo,
InternalEventPayload, ListCookieNamesResponse, ListHttpRequestsResponse,
ListWorkspacesResponse, RenderGrpcRequestResponse, RenderHttpRequestResponse,
SendHttpRequestResponse, SetKeyValueResponse, ShowToastRequest, TemplateRenderResponse,
WindowInfoResponse, WindowNavigateEvent, WorkspaceInfo,
};
use yaak_plugins::plugin_handle::PluginHandle;
use yaak_plugins::template_callback::PluginTemplateCallback;
@@ -107,7 +107,7 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
Workspace(app_handle.db().upsert_workspace(m, &UpdateSource::Plugin)?)
}
_ => {
return Err(PluginErr("Upsert not supported for this model type".into()).into())
return Err(PluginErr("Upsert not supported for this model type".into()).into());
}
};
@@ -118,14 +118,10 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
InternalEventPayload::DeleteModelRequest(req) => {
let model = match req.model.as_str() {
"http_request" => AnyModel::HttpRequest(
app_handle
.db()
.delete_http_request_by_id(&req.id, &UpdateSource::Plugin)?,
app_handle.db().delete_http_request_by_id(&req.id, &UpdateSource::Plugin)?,
),
"grpc_request" => AnyModel::GrpcRequest(
app_handle
.db()
.delete_grpc_request_by_id(&req.id, &UpdateSource::Plugin)?,
app_handle.db().delete_grpc_request_by_id(&req.id, &UpdateSource::Plugin)?,
),
"websocket_request" => AnyModel::WebsocketRequest(
app_handle
@@ -133,17 +129,13 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
.delete_websocket_request_by_id(&req.id, &UpdateSource::Plugin)?,
),
"folder" => AnyModel::Folder(
app_handle
.db()
.delete_folder_by_id(&req.id, &UpdateSource::Plugin)?,
app_handle.db().delete_folder_by_id(&req.id, &UpdateSource::Plugin)?,
),
"environment" => AnyModel::Environment(
app_handle
.db()
.delete_environment_by_id(&req.id, &UpdateSource::Plugin)?,
app_handle.db().delete_environment_by_id(&req.id, &UpdateSource::Plugin)?,
),
_ => {
return Err(PluginErr("Delete not supported for this model type".into()).into())
return Err(PluginErr("Delete not supported for this model type".into()).into());
}
};

View File

@@ -5,6 +5,9 @@ version = "0.1.0"
edition = "2024"
publish = false
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("cargo-clippy"))'] }
[build-dependencies]
tauri-plugin = { workspace = true, features = ["build"] }

View File

@@ -8,6 +8,10 @@ impl<'a> DbContext<'a> {
self.find_one(PluginIden::Id, id)
}
pub fn get_plugin_by_directory(&self, directory: &str) -> Option<Plugin> {
self.find_optional(PluginIden::Directory, directory)
}
pub fn list_plugins(&self) -> Result<Vec<Plugin>> {
self.find_all()
}

View File

@@ -423,7 +423,7 @@ export type ListCookieNamesRequest = {};
export type ListCookieNamesResponse = { names: Array<string>, };
export type ListFoldersRequest = Record<string, never>;
export type ListFoldersRequest = {};
export type ListFoldersResponse = { folders: Array<Folder>, };

View File

@@ -1351,8 +1351,8 @@ pub struct ListHttpRequestsResponse {
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
#[serde(default)]
#[ts(export, type = "{}", export_to = "gen_events.ts")]
pub struct ListFoldersRequest {}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]

View File

@@ -55,7 +55,7 @@ pub async fn download_and_install<R: Runtime>(
zip_extract::extract(Cursor::new(&bytes), &plugin_dir, true)?;
info!("Extracted plugin {} to {}", plugin_version.id, plugin_dir_str);
plugin_manager.add_plugin_by_dir(&PluginContext::new(&window), &plugin_dir_str).await?;
plugin_manager.add_plugin_by_dir(&PluginContext::new(&window), &plugin_dir_str, true).await?;
window.db().upsert_plugin(
&Plugin {

View File

@@ -1,5 +1,6 @@
use crate::error::Error::{
AuthPluginNotFound, ClientNotInitializedErr, PluginErr, PluginNotFoundErr, UnknownEventErr,
self, AuthPluginNotFound, ClientNotInitializedErr, PluginErr, PluginNotFoundErr,
UnknownEventErr,
};
use crate::error::Result;
use crate::events::{
@@ -35,10 +36,10 @@ use tokio::net::TcpListener;
use tokio::sync::mpsc::error::TrySendError;
use tokio::sync::{Mutex, mpsc};
use tokio::time::{Instant, timeout};
use yaak_models::models::Environment;
use yaak_models::models::{Environment, Plugin};
use yaak_models::query_manager::QueryManagerExt;
use yaak_models::render::make_vars_hashmap;
use yaak_models::util::generate_id;
use yaak_models::util::{UpdateSource, generate_id};
use yaak_templates::error::Error::RenderError;
use yaak_templates::error::Result as TemplateResult;
use yaak_templates::{RenderErrorBehavior, RenderOptions, render_json_value_raw};
@@ -46,18 +47,13 @@ use yaak_templates::{RenderErrorBehavior, RenderOptions, render_json_value_raw};
#[derive(Clone)]
pub struct PluginManager {
subscribers: Arc<Mutex<HashMap<String, mpsc::Sender<InternalEvent>>>>,
plugins: Arc<Mutex<Vec<PluginHandle>>>,
plugin_handles: Arc<Mutex<Vec<PluginHandle>>>,
kill_tx: tokio::sync::watch::Sender<bool>,
ws_service: Arc<PluginRuntimeServerWebsocket>,
vendored_plugin_dir: PathBuf,
pub(crate) installed_plugin_dir: PathBuf,
}
#[derive(Clone)]
struct PluginCandidate {
dir: String,
}
impl PluginManager {
pub fn new<R: Runtime>(app_handle: AppHandle<R>) -> PluginManager {
let (events_tx, mut events_rx) = mpsc::channel(128);
@@ -80,7 +76,7 @@ impl PluginManager {
.join("installed-plugins");
let plugin_manager = PluginManager {
plugins: Default::default(),
plugin_handles: Default::default(),
subscribers: Default::default(),
ws_service: Arc::new(ws_service.clone()),
kill_tx: kill_server_tx,
@@ -109,7 +105,7 @@ impl PluginManager {
// Handle when client plugin runtime disconnects
tauri::async_runtime::spawn(async move {
while let Some(_) = client_disconnect_rx.recv().await {
while (client_disconnect_rx.recv().await).is_some() {
// Happens when the app is closed
info!("Plugin runtime client disconnected");
}
@@ -163,10 +159,10 @@ impl PluginManager {
plugin_manager
}
async fn list_plugin_dirs<R: Runtime>(
async fn list_available_plugins<R: Runtime>(
&self,
app_handle: &AppHandle<R>,
) -> Vec<PluginCandidate> {
) -> Result<Vec<Plugin>> {
let plugins_dir = if is_dev() {
// Use plugins directly for easy development
env::current_dir()
@@ -178,18 +174,27 @@ impl PluginManager {
info!("Loading bundled plugins from {plugins_dir:?}");
let bundled_plugin_dirs: Vec<PluginCandidate> = read_plugins_dir(&plugins_dir)
// Read bundled plugin directories from disk
let bundled_plugin_dirs: Vec<String> = read_plugins_dir(&plugins_dir)
.await
.expect(format!("Failed to read plugins dir: {:?}", plugins_dir).as_str())
.iter()
.map(|d| PluginCandidate { dir: d.into() })
.collect();
.expect(&format!("Failed to read plugins dir: {:?}", plugins_dir));
let plugins = app_handle.db().list_plugins().unwrap_or_default();
let installed_plugin_dirs: Vec<PluginCandidate> =
plugins.iter().map(|p| PluginCandidate { dir: p.directory.to_owned() }).collect();
// Ensure all bundled plugins make it into the database
for dir in &bundled_plugin_dirs {
if app_handle.db().get_plugin_by_directory(dir).is_none() {
app_handle.db().upsert_plugin(
&Plugin {
directory: dir.clone(),
enabled: true,
url: None,
..Default::default()
},
&UpdateSource::Background,
)?;
}
}
[bundled_plugin_dirs, installed_plugin_dirs].concat()
Ok(app_handle.db().list_plugins()?)
}
pub async fn uninstall(&self, plugin_context: &PluginContext, dir: &str) -> Result<()> {
@@ -202,16 +207,18 @@ impl PluginManager {
plugin_context: &PluginContext,
plugin: &PluginHandle,
) -> Result<()> {
// Terminate the plugin
self.send_to_plugin_and_wait(
plugin_context,
plugin,
&InternalEventPayload::TerminateRequest,
)
.await?;
// Terminate the plugin if it's enabled
if plugin.enabled {
self.send_to_plugin_and_wait(
plugin_context,
plugin,
&InternalEventPayload::TerminateRequest,
)
.await?;
}
// Remove the plugin from the list
let mut plugins = self.plugins.lock().await;
let mut plugins = self.plugin_handles.lock().await;
let pos = plugins.iter().position(|p| p.ref_id == plugin.ref_id);
if let Some(pos) = pos {
plugins.remove(pos);
@@ -220,7 +227,12 @@ impl PluginManager {
Ok(())
}
pub async fn add_plugin_by_dir(&self, plugin_context: &PluginContext, dir: &str) -> Result<()> {
pub async fn add_plugin_by_dir(
&self,
plugin_context: &PluginContext,
dir: &str,
enabled: bool,
) -> Result<()> {
info!("Adding plugin by dir {dir}");
let maybe_tx = self.ws_service.app_to_plugin_events_tx.lock().await;
@@ -228,32 +240,32 @@ impl PluginManager {
None => return Err(ClientNotInitializedErr),
Some(tx) => tx,
};
let plugin_handle = PluginHandle::new(dir, tx.clone())?;
let plugin_handle = PluginHandle::new(dir, enabled, tx.clone())?;
let dir_path = Path::new(dir);
let is_vendored = dir_path.starts_with(self.vendored_plugin_dir.as_path());
let is_installed = dir_path.starts_with(self.installed_plugin_dir.as_path());
// Boot the plugin
let event = timeout(
Duration::from_secs(5),
self.send_to_plugin_and_wait(
plugin_context,
&plugin_handle,
&InternalEventPayload::BootRequest(BootRequest {
dir: dir.to_string(),
watch: !is_vendored && !is_installed,
}),
),
)
.await??;
// Boot the plugin if it's enabled
if enabled {
let event = self
.send_to_plugin_and_wait(
plugin_context,
&plugin_handle,
&InternalEventPayload::BootRequest(BootRequest {
dir: dir.to_string(),
watch: !is_vendored && !is_installed,
}),
)
.await?;
if !matches!(event.payload, InternalEventPayload::BootResponse) {
return Err(UnknownEventErr);
if !matches!(event.payload, InternalEventPayload::BootResponse) {
return Err(UnknownEventErr);
}
}
let mut plugins = self.plugins.lock().await;
plugins.retain(|p| p.dir != dir);
plugins.push(plugin_handle.clone());
let mut plugin_handles = self.plugin_handles.lock().await;
plugin_handles.retain(|p| p.dir != dir);
plugin_handles.push(plugin_handle.clone());
Ok(())
}
@@ -263,22 +275,24 @@ impl PluginManager {
app_handle: &AppHandle<R>,
plugin_context: &PluginContext,
) -> Result<()> {
info!("Initializing all plugins");
let start = Instant::now();
let candidates = self.list_plugin_dirs(app_handle).await;
for candidate in candidates.clone() {
// First remove the plugin if it exists
if let Some(plugin) = self.get_plugin_by_dir(candidate.dir.as_str()).await {
if let Err(e) = self.remove_plugin(plugin_context, &plugin).await {
error!("Failed to remove plugin {} {e:?}", candidate.dir);
for plugin in self.list_available_plugins(app_handle).await?.clone() {
// First remove the plugin if it exists and is enabled
if let Some(plugin_handle) = self.get_plugin_by_dir(&plugin.directory).await {
if let Err(e) = self.remove_plugin(plugin_context, &plugin_handle).await {
error!("Failed to remove plugin {} {e:?}", plugin.directory);
continue;
}
}
if let Err(e) = self.add_plugin_by_dir(plugin_context, candidate.dir.as_str()).await {
warn!("Failed to add plugin {} {e:?}", candidate.dir);
if let Err(e) =
self.add_plugin_by_dir(plugin_context, &plugin.directory, plugin.enabled).await
{
warn!("Failed to add plugin {} {e:?}", plugin.directory);
}
}
let plugins = self.plugins.lock().await;
let plugins = self.plugin_handles.lock().await;
let names = plugins.iter().map(|p| p.dir.to_string()).collect::<Vec<String>>();
info!(
"Initialized {} plugins in {:?}:\n - {}",
@@ -324,15 +338,15 @@ impl PluginManager {
}
pub async fn get_plugin_by_ref_id(&self, ref_id: &str) -> Option<PluginHandle> {
self.plugins.lock().await.iter().find(|p| p.ref_id == ref_id).cloned()
self.plugin_handles.lock().await.iter().find(|p| p.ref_id == ref_id).cloned()
}
pub async fn get_plugin_by_dir(&self, dir: &str) -> Option<PluginHandle> {
self.plugins.lock().await.iter().find(|p| p.dir == dir).cloned()
self.plugin_handles.lock().await.iter().find(|p| p.dir == dir).cloned()
}
pub async fn get_plugin_by_name(&self, name: &str) -> Option<PluginHandle> {
for plugin in self.plugins.lock().await.iter().cloned() {
for plugin in self.plugin_handles.lock().await.iter().cloned() {
let info = plugin.info();
if info.name == name {
return Some(plugin);
@@ -347,9 +361,19 @@ impl PluginManager {
plugin: &PluginHandle,
payload: &InternalEventPayload,
) -> Result<InternalEvent> {
if !plugin.enabled {
return Err(Error::PluginErr(format!("Plugin {} is disabled", plugin.metadata.name)));
}
let events =
self.send_to_plugins_and_wait(plugin_context, payload, vec![plugin.to_owned()]).await?;
Ok(events.first().unwrap().to_owned())
Ok(events
.first()
.ok_or(Error::PluginErr(format!(
"No plugin events returned for: {}",
plugin.metadata.name
)))?
.to_owned())
}
async fn send_and_wait(
@@ -357,7 +381,7 @@ impl PluginManager {
plugin_context: &PluginContext,
payload: &InternalEventPayload,
) -> Result<Vec<InternalEvent>> {
let plugins = { self.plugins.lock().await.clone() };
let plugins = { self.plugin_handles.lock().await.clone() };
self.send_to_plugins_and_wait(plugin_context, payload, plugins).await
}
@@ -373,6 +397,7 @@ impl PluginManager {
// 1. Build the events with IDs and everything
let events_to_send = plugins
.iter()
.filter(|p| p.enabled)
.map(|p| p.build_event_to_send(plugin_context, payload, None))
.collect::<Vec<InternalEvent>>();
@@ -383,19 +408,28 @@ impl PluginManager {
tokio::spawn(async move {
let mut found_events = Vec::new();
while let Some(event) = rx.recv().await {
let matched_sent_event = events_to_send
.iter()
.find(|e| Some(e.id.to_owned()) == event.reply_id)
.is_some();
if matched_sent_event {
found_events.push(event.clone());
};
let collect_events = async {
while let Some(event) = rx.recv().await {
let matched_sent_event =
events_to_send.iter().any(|e| Some(e.id.to_owned()) == event.reply_id);
if matched_sent_event {
found_events.push(event.clone());
};
let found_them_all = found_events.len() == events_to_send.len();
if found_them_all {
break;
let found_them_all = found_events.len() == events_to_send.len();
if found_them_all {
break;
}
}
};
// Timeout after 10 seconds to prevent hanging forever if plugin doesn't respond
if timeout(Duration::from_secs(5), collect_events).await.is_err() {
warn!(
"Timeout waiting for plugin responses. Got {}/{} responses",
found_events.len(),
events_to_send.len()
);
}
found_events
@@ -586,7 +620,7 @@ impl PluginManager {
// We don't want to fail for this op because the UI will not be able to list any auth types then
let render_opt = RenderOptions { error_behavior: RenderErrorBehavior::ReturnEmpty };
let rendered_values = render_json_value_raw(json!(values), vars, &cb, &render_opt).await?;
let context_id = format!("{:x}", md5::compute(model_id.to_string()));
let context_id = format!("{:x}", md5::compute(model_id));
let event = self
.send_to_plugin_and_wait(
@@ -754,7 +788,7 @@ impl PluginManager {
// We don't want to fail for this op because the UI will not be able to list any auth types then
let render_opt = RenderOptions { error_behavior: RenderErrorBehavior::ReturnEmpty };
let rendered_values = render_json_value_raw(json!(values), vars, &cb, &render_opt).await?;
let context_id = format!("{:x}", md5::compute(model_id.to_string()));
let context_id = format!("{:x}", md5::compute(model_id));
let event = self
.send_to_plugin_and_wait(
&PluginContext::new(window),
@@ -804,7 +838,7 @@ impl PluginManager {
.find_map(|(p, r)| if r.name == auth_name { Some(p) } else { None })
.ok_or(PluginNotFoundErr(auth_name.into()))?;
let context_id = format!("{:x}", md5::compute(model_id.to_string()));
let context_id = format!("{:x}", md5::compute(model_id));
self.send_to_plugin_and_wait(
&PluginContext::new(window),
&plugin,
@@ -831,7 +865,7 @@ impl PluginManager {
plugin_context: &PluginContext,
) -> Result<CallHttpAuthenticationResponse> {
let disabled = match req.values.get("disabled") {
Some(JsonPrimitive::Boolean(v)) => v.clone(),
Some(JsonPrimitive::Boolean(v)) => *v,
_ => false,
};

View File

@@ -10,12 +10,13 @@ use tokio::sync::{Mutex, mpsc};
pub struct PluginHandle {
pub ref_id: String,
pub dir: String,
pub enabled: bool,
pub(crate) to_plugin_tx: Arc<Mutex<mpsc::Sender<InternalEvent>>>,
pub(crate) metadata: PluginMetadata,
}
impl PluginHandle {
pub fn new(dir: &str, tx: mpsc::Sender<InternalEvent>) -> Result<Self> {
pub fn new(dir: &str, enabled: bool, tx: mpsc::Sender<InternalEvent>) -> Result<Self> {
let ref_id = gen_id();
let metadata = get_plugin_meta(&Path::new(dir))?;
@@ -23,6 +24,7 @@ impl PluginHandle {
ref_id: ref_id.clone(),
dir: dir.to_string(),
to_plugin_tx: Arc::new(Mutex::new(tx)),
enabled,
metadata,
})
}

View File

@@ -73,10 +73,17 @@ impl PluginRuntimeServerWebsocket {
// Skip non-text messages
if !msg.is_text() {
return;
warn!("Received non-text message from plugin runtime");
continue;
}
let msg_text = msg.into_text().unwrap();
let msg_text = match msg.into_text() {
Ok(text) => text,
Err(e) => {
error!("Failed to convert message to text: {e:?}");
continue;
}
};
let event = match serde_json::from_str::<InternalEventRawPayload>(&msg_text) {
Ok(e) => e,
Err(e) => {
@@ -117,9 +124,18 @@ impl PluginRuntimeServerWebsocket {
return;
},
Some(event) => {
let event_bytes = serde_json::to_string(&event).unwrap();
let event_bytes = match serde_json::to_string(&event) {
Ok(bytes) => bytes,
Err(e) => {
error!("Failed to serialize event: {:?}", e);
continue;
}
};
let msg = Message::text(event_bytes);
ws_sender.send(msg).await.unwrap();
if let Err(e) = ws_sender.send(msg).await {
error!("Failed to send message to plugin runtime: {:?}", e);
break;
}
}
}
}

View File

@@ -1,5 +1,5 @@
/* tslint:disable */
/* eslint-disable */
export function unescape_template(template: string): any;
export function escape_template(template: string): any;
export function parse_template(template: string): any;
export function escape_template(template: string): any;
export function unescape_template(template: string): any;

View File

@@ -165,10 +165,10 @@ function takeFromExternrefTable0(idx) {
* @param {string} template
* @returns {any}
*/
export function unescape_template(template) {
export function parse_template(template) {
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.unescape_template(ptr0, len0);
const ret = wasm.parse_template(ptr0, len0);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
@@ -193,10 +193,10 @@ export function escape_template(template) {
* @param {string} template
* @returns {any}
*/
export function parse_template(template) {
export function unescape_template(template) {
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.parse_template(ptr0, len0);
const ret = wasm.unescape_template(ptr0, len0);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}

Binary file not shown.