diff options
Diffstat (limited to 'src/app/api/plugins/runtime.rs')
| -rw-r--r-- | src/app/api/plugins/runtime.rs | 271 |
1 files changed, 271 insertions, 0 deletions
diff --git a/src/app/api/plugins/runtime.rs b/src/app/api/plugins/runtime.rs new file mode 100644 index 00000000..c6955bb4 --- /dev/null +++ b/src/app/api/plugins/runtime.rs @@ -0,0 +1,271 @@ +use std::io::Read; +use std::process::{Command, Stdio}; + +use super::manifest::{effective_platforms, ensure_platform_supported}; +use super::plugin_manifest_available; +use crate::api::schema::{ + InstalledPluginInfo, PluginCommandLogInfo, PluginCommandStatus, PluginInvocationContext, +}; +use crate::app::App; + +const PLUGIN_COMMAND_OUTPUT_MAX_BYTES: usize = 64 * 1024; +pub(super) const MAX_PLUGIN_COMMANDS_IN_FLIGHT: usize = 32; +const PLUGIN_COMMAND_LOG_LIMIT: usize = 200; + +impl App { + pub(super) fn start_plugin_command( + &mut self, + plugin: &InstalledPluginInfo, + action_id: Option<String>, + event: Option<String>, + command: Vec<String>, + context: &PluginInvocationContext, + event_json: Option<String>, + ) -> Result<PluginCommandLogInfo, (&'static str, String)> { + let Some(program) = command.first().cloned() else { + return Err(( + "invalid_plugin_command", + "command must not be empty".to_string(), + )); + }; + let args = command.iter().skip(1).cloned().collect::<Vec<_>>(); + let context_json = serde_json::to_string(context) + .map_err(|err| ("invalid_plugin_context", err.to_string()))?; + let log_id = format!("plugin-log-{}", self.state.next_plugin_command_log_id); + self.state.next_plugin_command_log_id += 1; + let started_unix_ms = current_unix_ms(); + let mut env = super::env::plugin_path_env(plugin); + env.extend([ + ( + crate::api::SOCKET_PATH_ENV_VAR.to_string(), + crate::api::socket_path().display().to_string(), + ), + ("HERDR_ENV".to_string(), "1".to_string()), + ("HERDR_PLUGIN_ID".to_string(), plugin.plugin_id.clone()), + ("HERDR_PLUGIN_CONTEXT_JSON".to_string(), context_json), + ]); + if let Ok(current_exe) = std::env::current_exe() { + env.push(( + "HERDR_BIN_PATH".to_string(), + current_exe.display().to_string(), + )); + } + if let Some(action_id) = action_id.as_ref() { + env.push(("HERDR_PLUGIN_ACTION_ID".to_string(), action_id.clone())); + } + if let Some(event) = event.as_ref() { + env.push(("HERDR_PLUGIN_EVENT".to_string(), event.clone())); + } + if let Some(event_json) = event_json { + env.push(("HERDR_PLUGIN_EVENT_JSON".to_string(), event_json)); + } + if let Some(workspace_id) = context.workspace_id.as_ref() { + env.push(("HERDR_WORKSPACE_ID".to_string(), workspace_id.clone())); + } + if let Some(tab_id) = context.tab_id.as_ref() { + env.push(("HERDR_TAB_ID".to_string(), tab_id.clone())); + } + if let Some(pane_id) = context.focused_pane_id.as_ref() { + env.push(("HERDR_PANE_ID".to_string(), pane_id.clone())); + } + if let Some(clicked_url) = context.clicked_url.as_ref() { + env.push(("HERDR_PLUGIN_CLICKED_URL".to_string(), clicked_url.clone())); + } + if let Some(link_handler_id) = context.link_handler_id.as_ref() { + env.push(( + "HERDR_PLUGIN_LINK_HANDLER_ID".to_string(), + link_handler_id.clone(), + )); + } + if self.state.plugin_commands_in_flight >= MAX_PLUGIN_COMMANDS_IN_FLIGHT { + let message = format!( + "maximum concurrent plugin commands reached ({MAX_PLUGIN_COMMANDS_IN_FLIGHT})" + ); + let log = PluginCommandLogInfo { + log_id, + plugin_id: plugin.plugin_id.clone(), + action_id, + event, + command, + status: PluginCommandStatus::Failed, + started_unix_ms, + finished_unix_ms: Some(started_unix_ms), + exit_code: None, + stdout: Some(String::new()), + stderr: Some(String::new()), + error: Some(message.clone()), + }; + self.push_plugin_command_log(log); + return Err(("plugin_command_limit_reached", message)); + } + let plugin_root = std::path::PathBuf::from(&plugin.plugin_root); + let log = PluginCommandLogInfo { + log_id: log_id.clone(), + plugin_id: plugin.plugin_id.clone(), + action_id, + event, + command: command.clone(), + status: PluginCommandStatus::Running, + started_unix_ms, + finished_unix_ms: None, + exit_code: None, + stdout: None, + stderr: None, + error: None, + }; + self.push_plugin_command_log(log.clone()); + self.state.plugin_commands_in_flight += 1; + let event_tx = self.event_tx.clone(); + std::thread::spawn(move || { + let child = Command::new(&program) + .args(args) + .current_dir(plugin_root) + .envs(env) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn(); + let finished = match child { + Ok(mut child) => { + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let stdout_reader = stdout.map(|stdout| { + std::thread::spawn(move || { + read_capped_plugin_output(stdout, PLUGIN_COMMAND_OUTPUT_MAX_BYTES) + }) + }); + let stderr_reader = stderr.map(|stderr| { + std::thread::spawn(move || { + read_capped_plugin_output(stderr, PLUGIN_COMMAND_OUTPUT_MAX_BYTES) + }) + }); + match child.wait() { + Ok(status) => crate::events::AppEvent::PluginCommandFinished { + log_id, + finished_unix_ms: current_unix_ms(), + exit_code: status.code(), + stdout: stdout_reader + .and_then(|reader| reader.join().ok()) + .unwrap_or_default(), + stderr: stderr_reader + .and_then(|reader| reader.join().ok()) + .unwrap_or_default(), + error: None, + }, + Err(err) => crate::events::AppEvent::PluginCommandFinished { + log_id, + finished_unix_ms: current_unix_ms(), + exit_code: None, + stdout: stdout_reader + .and_then(|reader| reader.join().ok()) + .unwrap_or_default(), + stderr: stderr_reader + .and_then(|reader| reader.join().ok()) + .unwrap_or_default(), + error: Some(err.to_string()), + }, + } + } + Err(err) => crate::events::AppEvent::PluginCommandFinished { + log_id, + finished_unix_ms: current_unix_ms(), + exit_code: None, + stdout: String::new(), + stderr: String::new(), + error: Some(err.to_string()), + }, + }; + let _ = event_tx.blocking_send(finished); + }); + Ok(log) + } + + pub(crate) fn run_plugin_event_hooks(&mut self, event: &crate::api::schema::EventEnvelope) { + let event_name = event.event.dot_name(); + if !crate::api::schema::PLUGIN_HOOK_EVENT_KINDS.contains(&event.event) { + return; + } + let plugins = self + .state + .installed_plugins + .values() + .filter(|plugin| { + plugin.enabled + && plugin_manifest_available(plugin) + && plugin.events.iter().any(|hook| hook.on == event_name) + }) + .cloned() + .collect::<Vec<_>>(); + if plugins.is_empty() { + return; + } + let event_json = serde_json::to_string(event).ok(); + let context = self.plugin_context_for_event(event, event_name); + for plugin in plugins { + for hook in plugin.events.clone() { + if hook.on != event_name { + continue; + } + if ensure_platform_supported( + &effective_platforms(&hook.platforms, &plugin.platforms).clone(), + event_name, + ) + .is_err() + { + continue; + } + let _ = self.start_plugin_command( + &plugin, + None, + Some(event_name.to_string()), + hook.command.clone(), + &context, + event_json.clone(), + ); + } + } + } + + fn push_plugin_command_log(&mut self, log: PluginCommandLogInfo) { + self.state.plugin_command_logs.push(log); + if self.state.plugin_command_logs.len() > PLUGIN_COMMAND_LOG_LIMIT { + let extra = self.state.plugin_command_logs.len() - PLUGIN_COMMAND_LOG_LIMIT; + self.state.plugin_command_logs.drain(0..extra); + } + } +} + +fn current_unix_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64) + .unwrap_or(0) +} + +pub(super) fn read_capped_plugin_output(mut reader: impl Read, cap: usize) -> String { + let mut kept = Vec::with_capacity(cap.min(8192)); + let mut buf = [0u8; 8192]; + let mut truncated = false; + loop { + match reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + let remaining = cap.saturating_sub(kept.len()); + if remaining > 0 { + kept.extend_from_slice(&buf[..n.min(remaining)]); + } + if n > remaining { + truncated = true; + } + } + Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => break, + } + } + let mut output = String::from_utf8_lossy(&kept).into_owned(); + if truncated { + output.push_str(&format!( + "\n[herdr truncated plugin output after {cap} bytes]" + )); + } + output +} |
