Files
yaak-mountain-loop/crates-server/yaak-server/src/plugin_events.rs
T

583 lines
24 KiB
Rust

//! The bridge's plugin host.
//!
//! Same shape as the CLI's bridge (crates-cli/yaak-cli/src/plugin_events.rs):
//! subscribe to the plugin manager, let `handle_shared_plugin_event` answer
//! everything that is only a database question, and implement the rest here.
//!
//! Where it differs is that a UI is attached. The CLI answers a prompt from a
//! TTY and refuses when there isn't one; the bridge does what the desktop does
//! instead — pushes the event to the tab and waits for the reply keyed by the
//! event's id. Toasts, clipboard writes and external URLs go the same way,
//! because the browser is the only thing here that can show or do them.
use crate::events::EventHub;
use crate::session::SessionStore;
use serde_json::Value;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::task::JoinHandle;
use yaak::plugin_events::{
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
};
use yaak::render::{render_grpc_request, render_http_request};
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
use yaak_crypto::manager::EncryptionManager;
use yaak_http::cookies::get_cookie_value_from_jar;
use yaak_http::manager::HttpConnectionManager;
use yaak_models::blob_manager::BlobManager;
use yaak_models::models::Environment;
use yaak_models::queries::any_request::AnyRequest;
use yaak_models::query_manager::QueryManager;
use yaak_models::render::make_vars_hashmap;
use yaak_models::util::UpdateSource;
use yaak_plugins::events::{
EmptyPayload, ErrorResponse, GetCookieValueResponse, InternalEvent, InternalEventPayload,
ListCookieNamesResponse, ListOpenWorkspacesResponse, PluginContext, PromptTextResponse,
RenderGrpcRequestResponse, RenderHttpRequestResponse, SendHttpRequestResponse,
TemplateRenderResponse, WindowInfoResponse, WorkspaceInfo,
};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::plugin_handle::PluginHandle;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_templates::{RenderOptions, TemplateCallback, render_json_value_raw};
pub struct BridgePluginEventBridge {
rx_id: String,
task: JoinHandle<()>,
}
struct BridgeHostContext {
query_manager: QueryManager,
blob_manager: BlobManager,
plugin_manager: Arc<PluginManager>,
encryption_manager: Arc<EncryptionManager>,
connection_manager: Arc<HttpConnectionManager>,
response_dir: PathBuf,
events: EventHub,
session: SessionStore,
}
impl BridgePluginEventBridge {
#[allow(clippy::too_many_arguments)]
pub async fn start(
plugin_manager: Arc<PluginManager>,
query_manager: QueryManager,
blob_manager: BlobManager,
encryption_manager: Arc<EncryptionManager>,
connection_manager: Arc<HttpConnectionManager>,
data_dir: PathBuf,
events: EventHub,
session: SessionStore,
) -> Self {
let (rx_id, mut rx) = plugin_manager.subscribe("bridge").await;
let rx_id_for_task = rx_id.clone();
let pm = plugin_manager.clone();
let host_context = Arc::new(BridgeHostContext {
query_manager,
blob_manager,
plugin_manager,
encryption_manager,
connection_manager,
response_dir: data_dir.join("responses"),
events,
session,
});
let task = tokio::spawn(async move {
while let Some(event) = rx.recv().await {
// Events with reply IDs are replies to app-originated requests.
if event.reply_id.is_some() {
continue;
}
let Some(plugin_handle) = pm.get_plugin_by_ref_id(&event.plugin_ref_id).await
else {
log::warn!(
"Ignoring plugin event with unknown plugin ref '{}'",
event.plugin_ref_id
);
continue;
};
let pm = pm.clone();
let host_context = host_context.clone();
// Avoid deadlocks for nested plugin-host requests (for example, template functions
// that trigger additional host requests during render) by handling each event in
// its own task.
tokio::spawn(async move {
let plugin_name = plugin_handle.info().name;
let Some(reply_payload) = build_plugin_reply(
host_context.as_ref(),
&event,
&plugin_name,
&plugin_handle,
)
.await
else {
return;
};
if let Err(err) = pm.reply(&event, &reply_payload).await {
log::warn!("Failed replying to plugin event: {err}");
}
});
}
pm.unsubscribe(&rx_id_for_task).await;
});
Self { rx_id, task }
}
pub async fn shutdown(self, plugin_manager: &PluginManager) {
plugin_manager.unsubscribe(&self.rx_id).await;
self.task.abort();
let _ = self.task.await;
}
}
async fn build_plugin_reply(
host_context: &BridgeHostContext,
event: &InternalEvent,
plugin_name: &str,
plugin_handle: &PluginHandle,
) -> Option<InternalEventPayload> {
let session = host_context.session.get();
let shared_workspace_id =
event.context.workspace_id.clone().or_else(|| session.workspace_id());
match handle_shared_plugin_event(
&host_context.query_manager,
&event.payload,
SharedPluginEventContext {
plugin_name,
workspace_id: shared_workspace_id.as_deref(),
},
) {
GroupedPluginEvent::Handled(payload) => payload,
GroupedPluginEvent::ToHandle(host_request) => match host_request {
HostRequest::ErrorResponse(resp) => {
log::warn!("[plugin:{plugin_name}] error: {}", resp.error);
None
}
HostRequest::ReloadResponse(_) => None,
// The tab owns everything the user can see or the OS can do. These
// are fire-and-forget: the plugin gets its acknowledgement as soon
// as the frame is queued, matching the desktop, which also does not
// wait for the webview to paint.
HostRequest::ShowToast(req) => {
host_context.events.emit("show_toast", &req);
Some(InternalEventPayload::ShowToastResponse(EmptyPayload {}))
}
HostRequest::CopyText(req) => {
host_context.events.emit("bridge_copy_text", &req);
Some(InternalEventPayload::CopyTextResponse(EmptyPayload {}))
}
HostRequest::OpenExternalUrl(req) => {
host_context.events.emit("bridge_open_url", &req);
Some(InternalEventPayload::OpenExternalUrlResponse(EmptyPayload {}))
}
// Prompts are questions, so they round-trip: the tab renders the
// dialog and emits the answer back under the event's own id.
HostRequest::PromptText(_) => {
let reply = call_frontend(host_context, event).await;
Some(reply.unwrap_or(InternalEventPayload::PromptTextResponse(
PromptTextResponse { value: None },
)))
}
// A form streams: the tab sends a response per interaction and the
// plugin re-renders, until one comes back marked done.
HostRequest::PromptForm(_) => {
host_context.events.emit("plugin_event", event);
if event.reply_id.is_none() {
spawn_form_reply_pump(host_context, event, plugin_handle);
}
None
}
HostRequest::ListOpenWorkspaces(_) => {
let workspaces = match host_context.query_manager.connect().list_workspaces() {
Ok(workspaces) => workspaces
.into_iter()
.map(|w| WorkspaceInfo {
id: w.id.clone(),
name: w.name,
label: session.label.clone(),
})
.collect(),
Err(err) => {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to list workspaces in bridge: {err}"),
}));
}
};
Some(InternalEventPayload::ListOpenWorkspacesResponse(ListOpenWorkspacesResponse {
workspaces,
}))
}
HostRequest::SendHttpRequest(req) => {
let mut http_request = req.http_request.clone();
if http_request.workspace_id.is_empty() {
let Some(workspace_id) = shared_workspace_id.clone() else {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: "workspace_id is required to send HTTP requests in bridge"
.to_string(),
}));
};
http_request.workspace_id = workspace_id;
}
let cookie_jar_id = match session.cookie_jar_id() {
Some(id) => Some(id),
None => match host_context
.query_manager
.connect()
.list_cookie_jars(http_request.workspace_id.as_str())
{
Ok(jars) => {
jars.into_iter().min_by_key(|jar| jar.created_at).map(|jar| jar.id)
}
Err(err) => {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to list cookie jars in bridge: {err}"),
}));
}
},
};
let plugin_context = PluginContext {
workspace_id: Some(http_request.workspace_id.clone()),
..event.context.clone()
};
match send_http_request_with_plugins(SendHttpRequestWithPluginsParams {
query_manager: &host_context.query_manager,
blob_manager: &host_context.blob_manager,
request: http_request,
environment_id: session.environment_id().as_deref(),
update_source: UpdateSource::Plugin,
cookie_jar_id,
response_dir: &host_context.response_dir,
emit_events_to: None,
emit_response_body_chunks_to: None,
existing_response: None,
plugin_manager: host_context.plugin_manager.clone(),
encryption_manager: host_context.encryption_manager.clone(),
plugin_context: &plugin_context,
cancelled_rx: None,
connection_manager: &host_context.connection_manager,
})
.await
{
Ok(result) => Some(InternalEventPayload::SendHttpRequestResponse(
SendHttpRequestResponse { http_response: result.response },
)),
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to send HTTP request in bridge: {err}"),
})),
}
}
HostRequest::RenderHttpRequest(req) => {
let mut http_request = req.http_request.clone();
if http_request.workspace_id.is_empty() {
let Some(workspace_id) = shared_workspace_id.clone() else {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: "workspace_id is required to render HTTP requests in bridge"
.to_string(),
}));
};
http_request.workspace_id = workspace_id;
}
let plugin_context = PluginContext {
workspace_id: Some(http_request.workspace_id.clone()),
..event.context.clone()
};
let environment_chain = match host_context.query_manager.connect().resolve_environments(
&http_request.workspace_id,
http_request.folder_id.as_deref(),
session.environment_id().as_deref(),
) {
Ok(chain) => chain,
Err(err) => {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to resolve environments in bridge: {err}"),
}));
}
};
let template_callback = PluginTemplateCallback::new(
host_context.plugin_manager.clone(),
host_context.encryption_manager.clone(),
&plugin_context,
req.purpose.clone(),
);
match render_http_request(
&http_request,
environment_chain,
&template_callback,
&RenderOptions::throw(),
)
.await
{
Ok(http_request) => Some(InternalEventPayload::RenderHttpRequestResponse(
RenderHttpRequestResponse { http_request },
)),
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to render HTTP request in bridge: {err}"),
})),
}
}
HostRequest::RenderGrpcRequest(req) => {
let mut grpc_request = req.grpc_request.clone();
if grpc_request.workspace_id.is_empty() {
let Some(workspace_id) = shared_workspace_id.clone() else {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: "workspace_id is required to render gRPC requests in bridge"
.to_string(),
}));
};
grpc_request.workspace_id = workspace_id;
}
let plugin_context = PluginContext {
workspace_id: Some(grpc_request.workspace_id.clone()),
..event.context.clone()
};
let environment_chain = match host_context.query_manager.connect().resolve_environments(
&grpc_request.workspace_id,
grpc_request.folder_id.as_deref(),
session.environment_id().as_deref(),
) {
Ok(chain) => chain,
Err(err) => {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to resolve environments in bridge: {err}"),
}));
}
};
let template_callback = PluginTemplateCallback::new(
host_context.plugin_manager.clone(),
host_context.encryption_manager.clone(),
&plugin_context,
req.purpose.clone(),
);
match render_grpc_request(
&grpc_request,
environment_chain,
&template_callback,
&RenderOptions::throw(),
)
.await
{
Ok(grpc_request) => Some(InternalEventPayload::RenderGrpcRequestResponse(
RenderGrpcRequestResponse { grpc_request },
)),
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to render gRPC request in bridge: {err}"),
})),
}
}
HostRequest::TemplateRender(req) => {
let Some(workspace_id) = shared_workspace_id.clone() else {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: "workspace_id is required to render templates in bridge".to_string(),
}));
};
let plugin_context =
PluginContext { workspace_id: Some(workspace_id.clone()), ..event.context.clone() };
let folder_id = session.request_id().and_then(|rid| {
match host_context.query_manager.connect().get_any_request(&rid) {
Ok(AnyRequest::HttpRequest(r)) => r.folder_id,
Ok(AnyRequest::GrpcRequest(r)) => r.folder_id,
Ok(AnyRequest::WebsocketRequest(r)) => r.folder_id,
Err(_) => None,
}
});
let environment_chain = match host_context.query_manager.connect().resolve_environments(
&workspace_id,
folder_id.as_deref(),
session.environment_id().as_deref(),
) {
Ok(chain) => chain,
Err(err) => {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to resolve environments in bridge: {err}"),
}));
}
};
let template_callback = PluginTemplateCallback::new(
host_context.plugin_manager.clone(),
host_context.encryption_manager.clone(),
&plugin_context,
req.purpose.clone(),
);
match render_json_value(
req.data.clone(),
environment_chain,
&template_callback,
&RenderOptions::throw(),
)
.await
{
Ok(data) => {
Some(InternalEventPayload::TemplateRenderResponse(TemplateRenderResponse {
data,
}))
}
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to render template data in bridge: {err}"),
})),
}
}
HostRequest::ListCookieNames(_) => {
let Some(cookie_jar_id) = session.cookie_jar_id() else {
return Some(InternalEventPayload::ListCookieNamesResponse(
ListCookieNamesResponse { names: Vec::new() },
));
};
match host_context.query_manager.connect().get_cookie_jar(&cookie_jar_id) {
Ok(jar) => Some(InternalEventPayload::ListCookieNamesResponse(
ListCookieNamesResponse {
names: jar.cookies.into_iter().map(|c| c.name).collect(),
},
)),
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to load cookie jar in bridge: {err}"),
})),
}
}
HostRequest::GetCookieValue(req) => {
let Some(cookie_jar_id) = session.cookie_jar_id() else {
return Some(InternalEventPayload::GetCookieValueResponse(
GetCookieValueResponse { value: None },
));
};
match host_context.query_manager.connect().get_cookie_jar(&cookie_jar_id) {
Ok(jar) => {
let value =
get_cookie_value_from_jar(jar.cookies, &req.name, req.domain.as_deref());
Some(InternalEventPayload::GetCookieValueResponse(GetCookieValueResponse {
value,
}))
}
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to load cookie jar in bridge: {err}"),
})),
}
}
HostRequest::WindowInfo(req) => {
Some(InternalEventPayload::WindowInfoResponse(WindowInfoResponse {
label: req.label.clone(),
request_id: session.request_id(),
workspace_id: shared_workspace_id.clone(),
environment_id: session.environment_id(),
}))
}
// A tab is one window. Opening and closing them needs the
// multiWindow capability the bridge reports false.
HostRequest::OpenWindow(_) => Some(unsupported("open_window_request")),
HostRequest::CloseWindow(_) => Some(unsupported("close_window_request")),
HostRequest::OtherRequest(payload) => Some(unsupported(&payload.type_name())),
},
}
}
fn unsupported(type_name: &str) -> InternalEventPayload {
InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Unsupported plugin request in bridge: {type_name}"),
})
}
/// Ask the tab and wait for its answer, keyed by the event's id — the same
/// contract as the desktop's `call_frontend`.
async fn call_frontend(
host_context: &BridgeHostContext,
event: &InternalEvent,
) -> Option<InternalEventPayload> {
// Subscribe before emitting: the tab can answer faster than this task is
// rescheduled, and a reply that arrives before the listener exists is lost.
let mut replies = host_context.events.subscribe_inbound(event.id.clone());
host_context.events.emit("plugin_event", event);
let value = replies.recv().await?;
match serde_json::from_value::<InternalEvent>(value) {
Ok(reply) => Some(reply.payload),
Err(e) => {
log::warn!("Failed to parse plugin reply from browser: {e}");
None
}
}
}
/// Forward every form response the tab sends back to the plugin, until one is
/// marked done.
fn spawn_form_reply_pump(
host_context: &BridgeHostContext,
event: &InternalEvent,
plugin_handle: &PluginHandle,
) {
let mut replies = host_context.events.subscribe_inbound(event.id.clone());
let plugin_handle = plugin_handle.clone();
let plugin_context = event.context.clone();
tokio::spawn(async move {
while let Some(value) = replies.recv().await {
let Ok(resp) = serde_json::from_value::<InternalEvent>(value) else {
log::warn!("Failed to parse form response from browser");
continue;
};
let is_done = matches!(
&resp.payload,
InternalEventPayload::PromptFormResponse(r) if r.done.unwrap_or(false)
);
let event_to_send = plugin_handle.build_event_to_send(
&plugin_context,
&resp.payload,
Some(resp.reply_id.unwrap_or_default()),
);
if let Err(e) = plugin_handle.send(&event_to_send).await {
log::warn!("Failed to forward form response to plugin: {e:?}");
}
if is_done {
break;
}
}
});
}
async fn render_json_value<T: TemplateCallback>(
value: Value,
environment_chain: Vec<Environment>,
cb: &T,
opt: &RenderOptions,
) -> yaak_templates::error::Result<Value> {
let vars = &make_vars_hashmap(environment_chain);
render_json_value_raw(value, vars, cb, opt).await
}