use std::io::Read; use std::process::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, event: Option, command: Vec, context: &PluginInvocationContext, event_json: Option, ) -> Result { 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::>(); let context_json = serde_json::to_string(context) .map_err(|err| ("invalid_plugin_context", err.to_string()))?; super::env::ensure_plugin_user_dirs(plugin) .map_err(|err| ("plugin_user_dir_create_failed", 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 = crate::plugin_command::command_for_argv(&program, &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::>(); 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 }