aboutsummaryrefslogtreecommitdiff
path: root/src/app/api/plugins/runtime.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-14 02:43:45 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-14 03:47:07 +0300
commitfbd20ad6e488fb3b9efc1612fdffee6e672976c1 (patch)
treed21daf93241786f8aa6fde23b41cdb9e15182b79 /src/app/api/plugins/runtime.rs
parentd35c642ef36ee5ac2afe7c1b18ad5a717bc807d8 (diff)
feat: add plugin v1 system
Add manifest-driven plugin actions, event hooks, pane entrypoints, link handlers, and host APIs. Support plugin link, GitHub install, build commands, installed plugin registry, pane movement, runtime context, and plugin docs.
Diffstat (limited to 'src/app/api/plugins/runtime.rs')
-rw-r--r--src/app/api/plugins/runtime.rs271
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
+}